Merge branch 'main' into feature/prisma-to-raw-query-phase1-complete

This commit is contained in:
kjs
2025-10-01 15:07:07 +09:00
33 changed files with 5495 additions and 250 deletions

View File

@@ -3450,6 +3450,13 @@
"integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==",
"license": "MIT"
},
"node_modules/@types/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/yargs": {
"version": "17.0.33",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz",

View File

@@ -43,6 +43,7 @@ import entityReferenceRoutes from "./routes/entityReferenceRoutes";
import externalCallRoutes from "./routes/externalCallRoutes";
import externalCallConfigRoutes from "./routes/externalCallConfigRoutes";
import dataflowExecutionRoutes from "./routes/dataflowExecutionRoutes";
import dashboardRoutes from "./routes/dashboardRoutes";
import { BatchSchedulerService } from "./services/batchSchedulerService";
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
@@ -171,6 +172,7 @@ app.use("/api/entity-reference", entityReferenceRoutes);
app.use("/api/external-calls", externalCallRoutes);
app.use("/api/external-call-configs", externalCallConfigRoutes);
app.use("/api/dataflow", dataflowExecutionRoutes);
app.use("/api/dashboards", dashboardRoutes);
// app.use("/api/collections", collectionRoutes); // 임시 주석
// app.use("/api/batch", batchRoutes); // 임시 주석
// app.use('/api/users', userRoutes);

View File

@@ -0,0 +1,436 @@
import { Response } from 'express';
import { AuthenticatedRequest } from '../middleware/authMiddleware';
import { DashboardService } from '../services/DashboardService';
import { CreateDashboardRequest, UpdateDashboardRequest, DashboardListQuery } from '../types/dashboard';
import { PostgreSQLService } from '../database/PostgreSQLService';
/**
* 대시보드 컨트롤러
* - REST API 엔드포인트 처리
* - 요청 검증 및 응답 포맷팅
*/
export class DashboardController {
/**
* 대시보드 생성
* POST /api/dashboards
*/
async createDashboard(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const userId = req.user?.userId;
if (!userId) {
res.status(401).json({
success: false,
message: '인증이 필요합니다.'
});
return;
}
const { title, description, elements, isPublic = false, tags, category }: 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
};
// console.log('대시보드 생성 시작:', { title: dashboardData.title, userId, elementsCount: elements.length });
const savedDashboard = await DashboardService.createDashboard(dashboardData, userId);
// 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<void> {
try {
const userId = req.user?.userId;
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);
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<void> {
try {
const { id } = req.params;
const userId = req.user?.userId;
if (!id) {
res.status(400).json({
success: false,
message: '대시보드 ID가 필요합니다.'
});
return;
}
const dashboard = await DashboardService.getDashboardById(id, userId);
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<void> {
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<void> {
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<void> {
try {
const userId = req.user?.userId;
if (!userId) {
res.status(401).json({
success: false,
message: '인증이 필요합니다.'
});
return;
}
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: userId // 본인이 만든 대시보드만
};
const result = await DashboardService.getDashboards(query, userId);
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
});
}
}
/**
* 쿼리 실행
* POST /api/dashboards/execute-query
*/
async executeQuery(req: AuthenticatedRequest, res: Response): Promise<void> {
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 : '쿼리 실행 오류'
});
}
}
}

View File

@@ -0,0 +1,127 @@
import { Pool, PoolClient, QueryResult } from 'pg';
import config from '../config/environment';
/**
* PostgreSQL Raw Query 서비스
* Prisma 대신 직접 pg 라이브러리를 사용
*/
export class PostgreSQLService {
private static pool: Pool;
/**
* 데이터베이스 연결 풀 초기화
*/
static initialize() {
if (!this.pool) {
this.pool = new Pool({
connectionString: config.databaseUrl,
max: 20, // 최대 연결 수
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
// 연결 풀 이벤트 리스너
this.pool.on('connect', () => {
console.log('🔗 PostgreSQL 연결 성공');
});
this.pool.on('error', (err) => {
console.error('❌ PostgreSQL 연결 오류:', err);
});
}
}
/**
* 연결 풀 가져오기
*/
static getPool(): Pool {
if (!this.pool) {
this.initialize();
}
return this.pool;
}
/**
* 단일 쿼리 실행
*/
static async query(text: string, params?: any[]): Promise<QueryResult> {
const pool = this.getPool();
const start = Date.now();
try {
const result = await pool.query(text, params);
const duration = Date.now() - start;
if (config.debug) {
console.log('🔍 Query executed:', { text, duration: `${duration}ms`, rows: result.rowCount });
}
return result;
} catch (error) {
console.error('❌ Query error:', { text, params, error });
throw error;
}
}
/**
* 트랜잭션 실행
*/
static async transaction<T>(callback: (client: PoolClient) => Promise<T>): Promise<T> {
const pool = this.getPool();
const client = await pool.connect();
try {
await client.query('BEGIN');
const result = await callback(client);
await client.query('COMMIT');
return result;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
/**
* 연결 테스트
*/
static async testConnection(): Promise<boolean> {
try {
const result = await this.query('SELECT NOW() as current_time');
console.log('✅ PostgreSQL 연결 테스트 성공:', result.rows[0]);
return true;
} catch (error) {
console.error('❌ PostgreSQL 연결 테스트 실패:', error);
return false;
}
}
/**
* 연결 풀 종료
*/
static async close(): Promise<void> {
if (this.pool) {
await this.pool.end();
console.log('🔒 PostgreSQL 연결 풀 종료');
}
}
}
// 애플리케이션 시작 시 초기화
PostgreSQLService.initialize();
// 프로세스 종료 시 연결 정리
process.on('SIGINT', async () => {
await PostgreSQLService.close();
process.exit(0);
});
process.on('SIGTERM', async () => {
await PostgreSQLService.close();
process.exit(0);
});
process.on('beforeExit', async () => {
await PostgreSQLService.close();
});

View File

@@ -0,0 +1,37 @@
import { Router } from 'express';
import { DashboardController } from '../controllers/DashboardController';
import { authenticateToken } from '../middleware/authMiddleware';
const router = Router();
const dashboardController = new DashboardController();
/**
* 대시보드 API 라우트
*
* 모든 엔드포인트는 인증이 필요하지만,
* 공개 대시보드 조회는 인증 없이도 가능
*/
// 공개 대시보드 목록 조회 (인증 불필요)
router.get('/public', dashboardController.getDashboards.bind(dashboardController));
// 공개 대시보드 상세 조회 (인증 불필요)
router.get('/public/:id', dashboardController.getDashboard.bind(dashboardController));
// 쿼리 실행 (인증 불필요 - 개발용)
router.post('/execute-query', dashboardController.executeQuery.bind(dashboardController));
// 인증이 필요한 라우트들
router.use(authenticateToken);
// 내 대시보드 목록 조회
router.get('/my', dashboardController.getMyDashboards.bind(dashboardController));
// 대시보드 CRUD
router.post('/', dashboardController.createDashboard.bind(dashboardController));
router.get('/', dashboardController.getDashboards.bind(dashboardController));
router.get('/:id', dashboardController.getDashboard.bind(dashboardController));
router.put('/:id', dashboardController.updateDashboard.bind(dashboardController));
router.delete('/:id', dashboardController.deleteDashboard.bind(dashboardController));
export default router;

View File

@@ -0,0 +1,534 @@
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<Dashboard> {
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<Dashboard | null> {
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<Dashboard | null> {
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<boolean> {
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<void> {
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<boolean> {
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;
}
}
}

View File

@@ -0,0 +1,90 @@
/**
* 대시보드 관련 타입 정의
*/
export interface DashboardElement {
id: string;
type: 'chart' | 'widget';
subtype: 'bar' | 'pie' | 'line' | 'exchange' | 'weather';
position: {
x: number;
y: number;
};
size: {
width: number;
height: number;
};
title: string;
content?: string;
dataSource?: {
type: 'api' | 'database' | 'static';
endpoint?: string;
query?: string;
refreshInterval?: number;
filters?: any[];
lastExecuted?: string;
};
chartConfig?: {
xAxis?: string;
yAxis?: string;
groupBy?: string;
aggregation?: 'sum' | 'avg' | 'count' | 'max' | 'min';
colors?: string[];
title?: string;
showLegend?: boolean;
};
}
export interface Dashboard {
id: string;
title: string;
description?: string;
thumbnailUrl?: string;
isPublic: boolean;
createdBy: string;
createdAt: string;
updatedAt: string;
deletedAt?: string;
tags?: string[];
category?: string;
viewCount: number;
elements: DashboardElement[];
}
export interface CreateDashboardRequest {
title: string;
description?: string;
isPublic?: boolean;
elements: DashboardElement[];
tags?: string[];
category?: string;
}
export interface UpdateDashboardRequest {
title?: string;
description?: string;
isPublic?: boolean;
elements?: DashboardElement[];
tags?: string[];
category?: string;
}
export interface DashboardListQuery {
page?: number;
limit?: number;
search?: string;
category?: string;
isPublic?: boolean;
createdBy?: string;
}
export interface DashboardShare {
id: string;
dashboardId: string;
sharedWithUser?: string;
sharedWithRole?: string;
permissionLevel: 'view' | 'edit' | 'admin';
createdBy: string;
createdAt: string;
expiresAt?: string;
}