From 25f62174338aec8c8fe679aa9610e4139591bbb0 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 28 Oct 2025 10:07:07 +0900 Subject: [PATCH] =?UTF-8?q?=ED=9A=8C=EC=82=AC=EB=B3=84=20=EB=A9=94?= =?UTF-8?q?=EB=89=B4=20=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20=EA=B6=8C=ED=95=9C?= =?UTF-8?q?=20=EA=B4=80=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursorrules | 502 ++++++++++++++++++ .../src/controllers/commonCodeController.ts | 85 ++- backend-node/src/controllers/ddlController.ts | 73 +++ .../src/controllers/flowController.ts | 70 +++ backend-node/src/routes/ddlRoutes.ts | 13 + backend-node/src/routes/flowRoutes.ts | 4 + backend-node/src/services/adminService.ts | 155 +++--- .../src/services/commonCodeService.ts | 188 +++++-- .../src/services/ddlExecutionService.ts | 118 ++++ frontend/app/(main)/admin/tableMng/page.tsx | 147 ++++- .../components/screen/widgets/FlowWidget.tsx | 258 +-------- frontend/lib/api/ddl.ts | 8 + frontend/lib/api/flow.ts | 22 + 13 files changed, 1273 insertions(+), 370 deletions(-) diff --git a/.cursorrules b/.cursorrules index 5f89313e..abbc2994 100644 --- a/.cursorrules +++ b/.cursorrules @@ -947,3 +947,505 @@ const visibleUsers = users.filter(user => { - API 응답에 최고 관리자 정보가 절대 포함되어서는 안 됩니다. - 로그에 필터링 여부를 기록하여 감사 추적을 남기세요. +--- + +## 멀티테넌시(Multi-Tenancy) 필수 규칙 + +### 핵심 원칙 + +**모든 데이터 조회/생성/수정/삭제 로직은 반드시 회사별(company_code)로 격리되어야 합니다.** + +이 시스템은 멀티테넌트 아키텍처를 사용하며, 각 회사(tenant)는 자신의 데이터만 접근할 수 있어야 합니다. + +### 1. 데이터베이스 스키마 요구사항 + +#### company_code 컬럼 필수 + +모든 비즈니스 테이블은 `company_code` 컬럼을 **반드시** 포함해야 합니다: + +```sql +CREATE TABLE example_table ( + id SERIAL PRIMARY KEY, + company_code VARCHAR(20) NOT NULL, -- 필수! + name VARCHAR(100), + created_at TIMESTAMPTZ DEFAULT NOW(), + CONSTRAINT fk_company FOREIGN KEY (company_code) + REFERENCES company_info(company_code) +); + +-- 성능을 위한 인덱스 (필수) +CREATE INDEX idx_example_company_code ON example_table(company_code); +``` + +#### 예외 테이블 + +다음 테이블들만 `company_code` 없이 전역 데이터를 저장할 수 있습니다: + +- `company_info` (회사 마스터 데이터) +- `user_info` (사용자는 company_code 보유) +- 시스템 설정 테이블 (`system_config` 등) +- 감사 로그 테이블 (`audit_log` 등) + +### 2. 백엔드 API 구현 필수 사항 + +#### 조회(SELECT) 쿼리 + +**모든 SELECT 쿼리는 company_code 필터링을 반드시 포함해야 합니다:** + +```typescript +// ✅ 올바른 방법 +async function getDataList(req: Request, res: Response) { + const companyCode = req.user!.companyCode; // 인증된 사용자의 회사 코드 + + const query = ` + SELECT * FROM example_table + WHERE company_code = $1 + ORDER BY created_at DESC + `; + + const result = await pool.query(query, [companyCode]); + + logger.info("데이터 조회", { + companyCode, + rowCount: result.rowCount + }); + + return res.json({ success: true, data: result.rows }); +} + +// ❌ 잘못된 방법 - company_code 필터링 없음 +async function getDataList(req: Request, res: Response) { + const query = `SELECT * FROM example_table`; // 모든 회사 데이터 노출! + const result = await pool.query(query); + return res.json({ success: true, data: result.rows }); +} +``` + +#### 생성(INSERT) 쿼리 + +**모든 INSERT 쿼리는 company_code를 반드시 포함해야 합니다:** + +```typescript +// ✅ 올바른 방법 +async function createData(req: Request, res: Response) { + const companyCode = req.user!.companyCode; + const { name, description } = req.body; + + const query = ` + INSERT INTO example_table (company_code, name, description) + VALUES ($1, $2, $3) + RETURNING * + `; + + const result = await pool.query(query, [companyCode, name, description]); + + logger.info("데이터 생성", { + companyCode, + id: result.rows[0].id + }); + + return res.json({ success: true, data: result.rows[0] }); +} + +// ❌ 잘못된 방법 - company_code 누락 +async function createData(req: Request, res: Response) { + const { name, description } = req.body; + + const query = ` + INSERT INTO example_table (name, description) + VALUES ($1, $2) + `; // company_code 누락! 다른 회사 데이터와 섞임 + + const result = await pool.query(query, [name, description]); + return res.json({ success: true, data: result.rows[0] }); +} +``` + +#### 수정(UPDATE) 쿼리 + +**WHERE 절에 company_code를 반드시 포함해야 합니다:** + +```typescript +// ✅ 올바른 방법 +async function updateData(req: Request, res: Response) { + const companyCode = req.user!.companyCode; + const { id } = req.params; + const { name, description } = req.body; + + const query = ` + UPDATE example_table + SET name = $1, description = $2, updated_at = NOW() + WHERE id = $3 AND company_code = $4 + RETURNING * + `; + + const result = await pool.query(query, [name, description, id, companyCode]); + + if (result.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "데이터를 찾을 수 없거나 권한이 없습니다" + }); + } + + logger.info("데이터 수정", { companyCode, id }); + + return res.json({ success: true, data: result.rows[0] }); +} + +// ❌ 잘못된 방법 - 다른 회사 데이터도 수정 가능 +async function updateData(req: Request, res: Response) { + const { id } = req.params; + const { name, description } = req.body; + + const query = ` + UPDATE example_table + SET name = $1, description = $2 + WHERE id = $3 + `; // 다른 회사의 같은 ID 데이터도 수정됨! + + const result = await pool.query(query, [name, description, id]); + return res.json({ success: true, data: result.rows[0] }); +} +``` + +#### 삭제(DELETE) 쿼리 + +**WHERE 절에 company_code를 반드시 포함해야 합니다:** + +```typescript +// ✅ 올바른 방법 +async function deleteData(req: Request, res: Response) { + const companyCode = req.user!.companyCode; + const { id } = req.params; + + const query = ` + DELETE FROM example_table + WHERE id = $1 AND company_code = $2 + RETURNING id + `; + + const result = await pool.query(query, [id, companyCode]); + + if (result.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "데이터를 찾을 수 없거나 권한이 없습니다" + }); + } + + logger.info("데이터 삭제", { companyCode, id }); + + return res.json({ success: true }); +} + +// ❌ 잘못된 방법 - 다른 회사 데이터도 삭제 가능 +async function deleteData(req: Request, res: Response) { + const { id } = req.params; + + const query = `DELETE FROM example_table WHERE id = $1`; + const result = await pool.query(query, [id]); + return res.json({ success: true }); +} +``` + +### 3. company_code = "*" 의미 + +**중요**: `company_code = "*"`는 **최고 관리자 전용 데이터**를 의미합니다. + +- ❌ 잘못된 이해: `company_code = "*"` = 모든 회사가 공유하는 공통 데이터 +- ✅ 올바른 이해: `company_code = "*"` = 최고 관리자만 관리하는 전용 데이터 + +**회사별 데이터 격리 원칙**: +- 회사 A (`company_code = "COMPANY_A"`): 회사 A 데이터만 조회/수정/삭제 가능 +- 회사 B (`company_code = "COMPANY_B"`): 회사 B 데이터만 조회/수정/삭제 가능 +- 최고 관리자 (`company_code = "*"`): 모든 회사 데이터 + 최고 관리자 전용 데이터 조회 가능 + +### 4. 최고 관리자(SUPER_ADMIN) 예외 처리 + +**최고 관리자(company_code = "*")는 모든 회사 데이터에 접근할 수 있습니다:** + +```typescript +async function getDataList(req: Request, res: Response) { + const companyCode = req.user!.companyCode; + + let query: string; + let params: any[]; + + if (companyCode === "*") { + // 최고 관리자: 모든 회사 데이터 조회 가능 + query = ` + SELECT * FROM example_table + ORDER BY company_code, created_at DESC + `; + params = []; + logger.info("최고 관리자 전체 데이터 조회"); + } else { + // 일반 회사: 자신의 회사 데이터만 조회 (company_code = "*" 데이터는 제외) + query = ` + SELECT * FROM example_table + WHERE company_code = $1 + ORDER BY created_at DESC + `; + params = [companyCode]; + logger.info("회사별 데이터 조회", { companyCode }); + } + + const result = await pool.query(query, params); + return res.json({ success: true, data: result.rows }); +} +``` + +**핵심**: 일반 회사 사용자는 `company_code = "*"` 데이터를 볼 수 없습니다! + +### 5. JOIN 쿼리에서의 멀티테넌시 + +**모든 JOIN된 테이블에도 company_code 필터링을 적용해야 합니다:** + +```typescript +// ✅ 올바른 방법 +const query = ` + SELECT + a.*, + b.name as category_name, + c.name as user_name + FROM example_table a + LEFT JOIN category_table b ON a.category_id = b.id + AND a.company_code = b.company_code -- JOIN 조건에도 company_code 필수 + LEFT JOIN user_info c ON a.user_id = c.user_id + AND a.company_code = c.company_code + WHERE a.company_code = $1 +`; + +// ❌ 잘못된 방법 - JOIN에서 다른 회사 데이터와 섞임 +const query = ` + SELECT + a.*, + b.name as category_name + FROM example_table a + LEFT JOIN category_table b ON a.category_id = b.id -- company_code 없음! + WHERE a.company_code = $1 +`; +``` + +### 6. 서비스 계층 패턴 + +**서비스 함수는 항상 companyCode를 첫 번째 파라미터로 받아야 합니다:** + +```typescript +// ✅ 올바른 서비스 패턴 +class ExampleService { + async findAll(companyCode: string, filters?: any) { + const query = ` + SELECT * FROM example_table + WHERE company_code = $1 + `; + return await pool.query(query, [companyCode]); + } + + async findById(companyCode: string, id: number) { + const query = ` + SELECT * FROM example_table + WHERE id = $1 AND company_code = $2 + `; + const result = await pool.query(query, [id, companyCode]); + return result.rows[0]; + } + + async create(companyCode: string, data: any) { + const query = ` + INSERT INTO example_table (company_code, name, description) + VALUES ($1, $2, $3) + RETURNING * + `; + const result = await pool.query(query, [companyCode, data.name, data.description]); + return result.rows[0]; + } +} + +// 컨트롤러에서 사용 +const exampleService = new ExampleService(); + +async function getDataList(req: Request, res: Response) { + const companyCode = req.user!.companyCode; + const data = await exampleService.findAll(companyCode, req.query); + return res.json({ success: true, data }); +} +``` + +### 7. 프론트엔드 고려사항 + +프론트엔드에서는 직접 company_code를 다루지 않습니다. 백엔드 API가 자동으로 처리합니다. + +```typescript +// ✅ 프론트엔드 - company_code 불필요 +async function fetchData() { + const response = await apiClient.get("/api/example/list"); + // 백엔드에서 자동으로 현재 사용자의 company_code로 필터링됨 + return response.data; +} + +// ❌ 프론트엔드에서 company_code를 수동으로 전달하지 않음 +async function fetchData(companyCode: string) { + const response = await apiClient.get(`/api/example/list?companyCode=${companyCode}`); + return response.data; +} +``` + +### 8. 마이그레이션 체크리스트 + +새로운 테이블이나 기능을 추가할 때 반드시 확인하세요: + +#### 데이터베이스 + +- [ ] 테이블에 `company_code VARCHAR(20) NOT NULL` 컬럼 추가 +- [ ] `company_info` 테이블에 대한 외래키 제약조건 추가 +- [ ] `company_code`에 인덱스 생성 +- [ ] 샘플 데이터에 올바른 `company_code` 값 포함 + +#### 백엔드 API + +- [ ] SELECT 쿼리에 `WHERE company_code = $1` 추가 +- [ ] INSERT 쿼리에 `company_code` 컬럼 포함 +- [ ] UPDATE/DELETE 쿼리의 WHERE 절에 `company_code` 조건 추가 +- [ ] JOIN 쿼리의 ON 절에 `company_code` 매칭 조건 추가 +- [ ] 최고 관리자(`company_code = "*"`) 예외 처리 +- [ ] 로그에 `companyCode` 정보 포함 + +#### 테스트 + +- [ ] 회사 A로 로그인하여 회사 A 데이터만 보이는지 확인 +- [ ] 회사 B로 로그인하여 회사 B 데이터만 보이는지 확인 +- [ ] 회사 A로 로그인하여 회사 B 데이터에 접근 불가능한지 확인 +- [ ] 최고 관리자로 로그인하여 모든 데이터가 보이는지 확인 +- [ ] 직접 SQL 인젝션 시도하여 다른 회사 데이터 접근 불가능 확인 + +### 9. 보안 주의사항 + +#### 클라이언트 입력 검증 + +```typescript +// ❌ 위험 - 클라이언트가 company_code를 지정할 수 있음 +async function createData(req: Request, res: Response) { + const { companyCode, name } = req.body; // 사용자가 임의의 회사 코드 전달 가능! + const query = `INSERT INTO example_table (company_code, name) VALUES ($1, $2)`; + await pool.query(query, [companyCode, name]); +} + +// ✅ 안전 - 인증된 사용자의 company_code만 사용 +async function createData(req: Request, res: Response) { + const companyCode = req.user!.companyCode; // 서버에서 확정 + const { name } = req.body; + const query = `INSERT INTO example_table (company_code, name) VALUES ($1, $2)`; + await pool.query(query, [companyCode, name]); +} +``` + +#### 감사 로그 + +모든 중요한 작업에 회사 정보를 로깅하세요: + +```typescript +logger.info("데이터 생성", { + companyCode: req.user!.companyCode, + userId: req.user!.userId, + tableName: "example_table", + action: "INSERT", + recordId: result.rows[0].id, +}); + +logger.warn("권한 없는 접근 시도", { + companyCode: req.user!.companyCode, + userId: req.user!.userId, + attemptedRecordId: req.params.id, + message: "다른 회사의 데이터 접근 시도", +}); +``` + +### 10. 일반적인 실수와 해결방법 + +#### 실수 1: 서브쿼리에서 company_code 누락 + +```typescript +// ❌ 잘못된 방법 +const query = ` + SELECT * FROM example_table + WHERE category_id IN ( + SELECT id FROM category_table WHERE active = true + ) + AND company_code = $1 +`; + +// ✅ 올바른 방법 +const query = ` + SELECT * FROM example_table + WHERE category_id IN ( + SELECT id FROM category_table + WHERE active = true AND company_code = $1 + ) + AND company_code = $1 +`; +``` + +#### 실수 2: COUNT/SUM 집계 함수 + +```typescript +// ❌ 잘못된 방법 - 모든 회사의 총합 +const query = `SELECT COUNT(*) as total FROM example_table`; + +// ✅ 올바른 방법 +const query = ` + SELECT COUNT(*) as total + FROM example_table + WHERE company_code = $1 +`; +``` + +#### 실수 3: EXISTS 서브쿼리 + +```typescript +// ❌ 잘못된 방법 +const query = ` + SELECT * FROM example_table a + WHERE EXISTS ( + SELECT 1 FROM related_table b WHERE b.example_id = a.id + ) + AND a.company_code = $1 +`; + +// ✅ 올바른 방법 +const query = ` + SELECT * FROM example_table a + WHERE EXISTS ( + SELECT 1 FROM related_table b + WHERE b.example_id = a.id + AND b.company_code = a.company_code + ) + AND a.company_code = $1 +`; +``` + +### 11. 참고 자료 + +- 마이그레이션 파일: `db/migrations/033_add_company_code_to_code_tables.sql` +- 멀티테넌시 분석 문서: `docs/멀티테넌시_구현_현황_분석_보고서.md` +- 사용자 관리 컨트롤러: `backend-node/src/controllers/adminController.ts` +- 인증 미들웨어: `backend-node/src/middleware/authMiddleware.ts` + +### 12. 요약 + +**모든 비즈니스 로직에서 회사별 데이터 격리는 필수입니다:** + +1. 모든 테이블에 `company_code` 컬럼 추가 +2. 모든 쿼리에 `company_code` 필터링 적용 +3. 인증된 사용자의 `req.user.companyCode` 사용 +4. 클라이언트 입력으로 `company_code`를 받지 않음 +5. 최고 관리자(`company_code = "*"`)는 모든 데이터 조회 가능 +6. **일반 회사는 `company_code = "*"` 데이터를 볼 수 없음** (최고 관리자 전용) +7. JOIN, 서브쿼리, 집계 함수에도 동일하게 적용 +8. 모든 작업을 로깅하여 감사 추적 가능 + +**절대 잊지 마세요: 멀티테넌시는 보안의 핵심입니다!** + +**company_code = "*"는 공통 데이터가 아닌 최고 관리자 전용 데이터입니다!** + diff --git a/backend-node/src/controllers/commonCodeController.ts b/backend-node/src/controllers/commonCodeController.ts index 840983a8..f31e55e1 100644 --- a/backend-node/src/controllers/commonCodeController.ts +++ b/backend-node/src/controllers/commonCodeController.ts @@ -21,14 +21,22 @@ export class CommonCodeController { async getCategories(req: AuthenticatedRequest, res: Response) { try { const { search, isActive, page = "1", size = "20" } = req.query; + const userCompanyCode = req.user?.companyCode; - const categories = await this.commonCodeService.getCategories({ - search: search as string, - isActive: - isActive === "true" ? true : isActive === "false" ? false : undefined, - page: parseInt(page as string), - size: parseInt(size as string), - }); + const categories = await this.commonCodeService.getCategories( + { + search: search as string, + isActive: + isActive === "true" + ? true + : isActive === "false" + ? false + : undefined, + page: parseInt(page as string), + size: parseInt(size as string), + }, + userCompanyCode + ); return res.json({ success: true, @@ -54,14 +62,23 @@ export class CommonCodeController { try { const { categoryCode } = req.params; const { search, isActive, page, size } = req.query; + const userCompanyCode = req.user?.companyCode; - const result = await this.commonCodeService.getCodes(categoryCode, { - search: search as string, - isActive: - isActive === "true" ? true : isActive === "false" ? false : undefined, - page: page ? parseInt(page as string) : undefined, - size: size ? parseInt(size as string) : undefined, - }); + const result = await this.commonCodeService.getCodes( + categoryCode, + { + search: search as string, + isActive: + isActive === "true" + ? true + : isActive === "false" + ? false + : undefined, + page: page ? parseInt(page as string) : undefined, + size: size ? parseInt(size as string) : undefined, + }, + userCompanyCode + ); // 프론트엔드가 기대하는 형식으로 데이터 변환 const transformedData = result.data.map((code: any) => ({ @@ -73,7 +90,8 @@ export class CommonCodeController { sortOrder: code.sort_order, isActive: code.is_active, useYn: code.is_active, - + companyCode: code.company_code, // 추가 + // 기존 필드명도 유지 (하위 호환성) code_category: code.code_category, code_value: code.code_value, @@ -81,6 +99,7 @@ export class CommonCodeController { code_name_eng: code.code_name_eng, sort_order: code.sort_order, is_active: code.is_active, + company_code: code.company_code, // 추가 created_date: code.created_date, created_by: code.created_by, updated_date: code.updated_date, @@ -110,7 +129,8 @@ export class CommonCodeController { async createCategory(req: AuthenticatedRequest, res: Response) { try { const categoryData: CreateCategoryData = req.body; - const userId = req.user?.userId || "SYSTEM"; // 인증 미들웨어에서 설정된 사용자 ID + const userId = req.user?.userId || "SYSTEM"; + const companyCode = req.user?.companyCode || "*"; // 입력값 검증 if (!categoryData.categoryCode || !categoryData.categoryName) { @@ -122,7 +142,8 @@ export class CommonCodeController { const category = await this.commonCodeService.createCategory( categoryData, - userId + userId, + companyCode ); return res.status(201).json({ @@ -135,7 +156,7 @@ export class CommonCodeController { // PostgreSQL 에러 처리 if ( - ((error as any)?.code === "23505") || // PostgreSQL unique_violation + (error as any)?.code === "23505" || // PostgreSQL unique_violation (error instanceof Error && error.message.includes("Unique constraint")) ) { return res.status(409).json({ @@ -161,11 +182,13 @@ export class CommonCodeController { const { categoryCode } = req.params; const categoryData: Partial = req.body; const userId = req.user?.userId || "SYSTEM"; + const companyCode = req.user?.companyCode; const category = await this.commonCodeService.updateCategory( categoryCode, categoryData, - userId + userId, + companyCode ); return res.json({ @@ -201,8 +224,9 @@ export class CommonCodeController { async deleteCategory(req: AuthenticatedRequest, res: Response) { try { const { categoryCode } = req.params; + const companyCode = req.user?.companyCode; - await this.commonCodeService.deleteCategory(categoryCode); + await this.commonCodeService.deleteCategory(categoryCode, companyCode); return res.json({ success: true, @@ -238,6 +262,7 @@ export class CommonCodeController { const { categoryCode } = req.params; const codeData: CreateCodeData = req.body; const userId = req.user?.userId || "SYSTEM"; + const companyCode = req.user?.companyCode || "*"; // 입력값 검증 if (!codeData.codeValue || !codeData.codeName) { @@ -250,7 +275,8 @@ export class CommonCodeController { const code = await this.commonCodeService.createCode( categoryCode, codeData, - userId + userId, + companyCode ); return res.status(201).json({ @@ -288,12 +314,14 @@ export class CommonCodeController { const { categoryCode, codeValue } = req.params; const codeData: Partial = req.body; const userId = req.user?.userId || "SYSTEM"; + const companyCode = req.user?.companyCode; const code = await this.commonCodeService.updateCode( categoryCode, codeValue, codeData, - userId + userId, + companyCode ); return res.json({ @@ -332,8 +360,13 @@ export class CommonCodeController { async deleteCode(req: AuthenticatedRequest, res: Response) { try { const { categoryCode, codeValue } = req.params; + const companyCode = req.user?.companyCode; - await this.commonCodeService.deleteCode(categoryCode, codeValue); + await this.commonCodeService.deleteCode( + categoryCode, + codeValue, + companyCode + ); return res.json({ success: true, @@ -370,8 +403,12 @@ export class CommonCodeController { async getCodeOptions(req: AuthenticatedRequest, res: Response) { try { const { categoryCode } = req.params; + const userCompanyCode = req.user?.companyCode; - const options = await this.commonCodeService.getCodeOptions(categoryCode); + const options = await this.commonCodeService.getCodeOptions( + categoryCode, + userCompanyCode + ); return res.json({ success: true, diff --git a/backend-node/src/controllers/ddlController.ts b/backend-node/src/controllers/ddlController.ts index 3fff2d73..20c2dc16 100644 --- a/backend-node/src/controllers/ddlController.ts +++ b/backend-node/src/controllers/ddlController.ts @@ -383,6 +383,79 @@ export class DDLController { } } + /** + * DELETE /api/ddl/tables/:tableName - 테이블 삭제 (최고 관리자 전용) + */ + static async dropTable( + req: AuthenticatedRequest, + res: Response + ): Promise { + try { + const { tableName } = req.params; + const userId = req.user!.userId; + const userCompanyCode = req.user!.companyCode; + + // 입력값 기본 검증 + if (!tableName) { + res.status(400).json({ + success: false, + error: { + code: "INVALID_INPUT", + details: "테이블명이 필요합니다.", + }, + }); + return; + } + + logger.info("테이블 삭제 요청", { + tableName, + userId, + userCompanyCode, + ip: req.ip, + }); + + // DDL 실행 서비스 호출 + const ddlService = new DDLExecutionService(); + const result = await ddlService.dropTable( + tableName, + userCompanyCode, + userId + ); + + if (result.success) { + res.status(200).json({ + success: true, + message: result.message, + data: { + tableName, + executedQuery: result.executedQuery, + }, + }); + } else { + res.status(400).json({ + success: false, + message: result.message, + error: result.error, + }); + } + } catch (error) { + logger.error("테이블 삭제 컨트롤러 오류:", { + error: (error as Error).message, + stack: (error as Error).stack, + userId: req.user?.userId, + tableName: req.params.tableName, + }); + + res.status(500).json({ + success: false, + error: { + code: "INTERNAL_SERVER_ERROR", + details: "테이블 삭제 중 서버 오류가 발생했습니다.", + }, + }); + } + } + /** * DELETE /api/ddl/logs/cleanup - 오래된 DDL 로그 정리 */ diff --git a/backend-node/src/controllers/flowController.ts b/backend-node/src/controllers/flowController.ts index 3ff159f1..16668b24 100644 --- a/backend-node/src/controllers/flowController.ts +++ b/backend-node/src/controllers/flowController.ts @@ -551,6 +551,76 @@ export class FlowController { } }; + /** + * 플로우 스텝의 컬럼 라벨 조회 + */ + getStepColumnLabels = async (req: Request, res: Response): Promise => { + try { + const { flowId, stepId } = req.params; + + const step = await this.flowStepService.getById(parseInt(stepId)); + if (!step) { + res.status(404).json({ + success: false, + message: "Step not found", + }); + return; + } + + const flowDef = await this.flowDefinitionService.getById( + parseInt(flowId) + ); + if (!flowDef) { + res.status(404).json({ + success: false, + message: "Flow definition not found", + }); + return; + } + + // 테이블명 결정 (스텝 테이블 우선, 없으면 플로우 테이블) + const tableName = step.tableName || flowDef.tableName; + if (!tableName) { + res.json({ + success: true, + data: {}, + }); + return; + } + + // column_labels 테이블에서 라벨 정보 조회 + const { query } = await import("../config/database"); + const labelRows = await query<{ + column_name: string; + column_label: string | null; + }>( + `SELECT column_name, column_label + FROM column_labels + WHERE table_name = $1 AND column_label IS NOT NULL`, + [tableName] + ); + + // { columnName: label } 형태의 객체로 변환 + const labels: Record = {}; + labelRows.forEach((row) => { + if (row.column_label) { + labels[row.column_name] = row.column_label; + } + }); + + res.json({ + success: true, + data: labels, + }); + } catch (error: any) { + console.error("Error getting step column labels:", error); + res.status(500).json({ + success: false, + message: error.message || "Failed to get step column labels", + }); + } + }; + /** * 플로우의 모든 단계별 카운트 조회 */ diff --git a/backend-node/src/routes/ddlRoutes.ts b/backend-node/src/routes/ddlRoutes.ts index ef80ede5..32986dca 100644 --- a/backend-node/src/routes/ddlRoutes.ts +++ b/backend-node/src/routes/ddlRoutes.ts @@ -42,6 +42,18 @@ router.post( DDLController.addColumn ); +/** + * 테이블 삭제 + * DELETE /api/ddl/tables/:tableName + */ +router.delete( + "/tables/:tableName", + authenticateToken, + requireSuperAdmin, + validateDDLPermission, + DDLController.dropTable +); + /** * 테이블 생성 사전 검증 (실제 생성하지 않고 검증만) * POST /api/ddl/validate/table @@ -135,6 +147,7 @@ router.get("/info", authenticateToken, requireSuperAdmin, (req, res) => { tables: { create: "POST /api/ddl/tables", addColumn: "POST /api/ddl/tables/:tableName/columns", + drop: "DELETE /api/ddl/tables/:tableName", getInfo: "GET /api/ddl/tables/:tableName/info", getHistory: "GET /api/ddl/tables/:tableName/history", }, diff --git a/backend-node/src/routes/flowRoutes.ts b/backend-node/src/routes/flowRoutes.ts index 08d1ac5f..5816fb8e 100644 --- a/backend-node/src/routes/flowRoutes.ts +++ b/backend-node/src/routes/flowRoutes.ts @@ -33,6 +33,10 @@ router.delete("/connections/:connectionId", flowController.deleteConnection); // ==================== 플로우 실행 ==================== router.get("/:flowId/step/:stepId/count", flowController.getStepDataCount); router.get("/:flowId/step/:stepId/list", flowController.getStepDataList); +router.get( + "/:flowId/step/:stepId/column-labels", + flowController.getStepColumnLabels +); router.get("/:flowId/steps/counts", flowController.getAllStepCounts); // ==================== 데이터 이동 ==================== diff --git a/backend-node/src/services/adminService.ts b/backend-node/src/services/adminService.ts index d1a7fdd2..4f2e926c 100644 --- a/backend-node/src/services/adminService.ts +++ b/backend-node/src/services/adminService.ts @@ -23,6 +23,7 @@ export class AdminService { // 1. 권한 그룹 기반 필터링 (좌측 사이드바인 경우만) let authFilter = ""; + let unionFilter = ""; // UNION ALL의 하위 메뉴 필터 let queryParams: any[] = [userLang]; let paramIndex = 2; @@ -51,17 +52,36 @@ export class AdminService { if (userRoleGroups.length > 0) { const roleObjids = userRoleGroups.map((rg: any) => rg.role_objid); // 루트 메뉴: 회사 코드만 체크 (권한 체크 X) - // 하위 메뉴: 회사 메뉴는 모두, 공통 메뉴는 권한 체크 authFilter = `AND MENU.COMPANY_CODE IN ($${paramIndex}, '*')`; queryParams.push(userCompanyCode); + const companyParamIndex = paramIndex; + paramIndex++; + + // 하위 메뉴: 회사 메뉴는 모두, 공통 메뉴는 권한 체크 + unionFilter = ` + AND ( + MENU_SUB.COMPANY_CODE = $${companyParamIndex} + OR ( + MENU_SUB.COMPANY_CODE = '*' + AND EXISTS ( + SELECT 1 + FROM rel_menu_auth rma + WHERE rma.menu_objid = MENU_SUB.OBJID + AND rma.auth_objid = ANY($${paramIndex}) + AND rma.read_yn = 'Y' + ) + ) + ) + `; queryParams.push(roleObjids); - paramIndex += 2; + paramIndex++; logger.info( `✅ 회사 관리자: 회사 ${userCompanyCode} 메뉴 전체 + 권한 있는 공통 메뉴` ); } else { // 권한 그룹이 없는 회사 관리자: 자기 회사 메뉴만 authFilter = `AND MENU.COMPANY_CODE = $${paramIndex}`; + unionFilter = `AND MENU_SUB.COMPANY_CODE = $${paramIndex}`; queryParams.push(userCompanyCode); paramIndex++; logger.info( @@ -81,6 +101,15 @@ export class AdminService { AND rma.read_yn = 'Y' ) `; + unionFilter = ` + AND EXISTS ( + SELECT 1 + FROM rel_menu_auth rma + WHERE rma.menu_objid = MENU_SUB.OBJID + AND rma.auth_objid = ANY($${paramIndex}) + AND rma.read_yn = 'Y' + ) + `; queryParams.push(roleObjids); paramIndex++; logger.info( @@ -97,6 +126,8 @@ export class AdminService { } else if (menuType !== undefined && userType === "SUPER_ADMIN") { // 좌측 사이드바 + SUPER_ADMIN: 권한 그룹 체크 없이 모든 공통 메뉴 표시 logger.info(`✅ 최고 관리자는 권한 그룹 체크 없이 모든 공통 메뉴 표시`); + // unionFilter는 비워둠 (하위 메뉴도 공통 메뉴만) + unionFilter = `AND MENU_SUB.COMPANY_CODE = '*'`; } // 2. 회사별 필터링 조건 생성 @@ -274,19 +305,7 @@ export class AdminService { JOIN V_MENU ON MENU_SUB.PARENT_OBJ_ID = V_MENU.OBJID WHERE MENU_SUB.OBJID != ANY(V_MENU.PATH) AND MENU_SUB.STATUS = 'active' - AND ( - MENU_SUB.COMPANY_CODE = $2 - OR ( - MENU_SUB.COMPANY_CODE = '*' - AND EXISTS ( - SELECT 1 - FROM rel_menu_auth rma - WHERE rma.menu_objid = MENU_SUB.OBJID - AND rma.auth_objid = ANY($3) - AND rma.read_yn = 'Y' - ) - ) - ) + ${unionFilter} ) SELECT LEVEL AS LEV, @@ -347,66 +366,82 @@ export class AdminService { const { userId, userCompanyCode, userType, userLang = "ko" } = paramMap; - // 1. 사용자가 속한 권한 그룹 조회 - const userRoleGroups = await query( - ` - SELECT DISTINCT am.objid AS role_objid, am.auth_name - FROM authority_master am - JOIN authority_sub_user asu ON am.objid = asu.master_objid - WHERE asu.user_id = $1 - AND am.status = 'active' - `, - [userId] - ); - - logger.info( - `✅ 사용자 ${userId}가 속한 권한 그룹: ${userRoleGroups.length}개`, - { - roleGroups: userRoleGroups.map((rg: any) => rg.auth_name), - } - ); - - // 2. 권한 그룹 기반 메뉴 필터 조건 생성 + // 1. 권한 그룹 기반 필터링 (SUPER_ADMIN은 제외) let authFilter = ""; + let unionFilter = ""; let queryParams: any[] = [userLang]; let paramIndex = 2; - if (userRoleGroups.length > 0) { - // 권한 그룹이 있는 경우: read_yn = 'Y'인 메뉴만 필터링 - const roleObjids = userRoleGroups.map((rg: any) => rg.role_objid); - authFilter = ` - AND EXISTS ( - SELECT 1 - FROM rel_menu_auth rma - WHERE rma.menu_objid = MENU.OBJID - AND rma.auth_objid = ANY($${paramIndex}) - AND rma.read_yn = 'Y' - ) - `; - queryParams.push(roleObjids); - paramIndex++; - logger.info( - `✅ 권한 그룹 기반 메뉴 필터링 적용: ${roleObjids.length}개 그룹` - ); + if (userType === "SUPER_ADMIN" && userCompanyCode === "*") { + // SUPER_ADMIN: 권한 그룹 체크 없이 공통 메뉴만 표시 + logger.info("✅ 좌측 사이드바 (SUPER_ADMIN): 공통 메뉴만 표시"); + authFilter = ""; + unionFilter = ""; } else { - // 권한 그룹이 없는 경우: 메뉴 없음 - logger.warn( - `⚠️ 사용자 ${userId}는 권한 그룹이 없어 메뉴가 표시되지 않습니다.` + // 일반 사용자 / 회사 관리자: 권한 그룹 조회 필요 + const userRoleGroups = await query( + ` + SELECT DISTINCT am.objid AS role_objid, am.auth_name + FROM authority_master am + JOIN authority_sub_user asu ON am.objid = asu.master_objid + WHERE asu.user_id = $1 + AND am.status = 'active' + `, + [userId] ); - return []; + + logger.info( + `✅ 사용자 ${userId}가 속한 권한 그룹: ${userRoleGroups.length}개`, + { + roleGroups: userRoleGroups.map((rg: any) => rg.auth_name), + } + ); + + if (userRoleGroups.length > 0) { + // 권한 그룹이 있는 경우: read_yn = 'Y'인 메뉴만 필터링 + const roleObjids = userRoleGroups.map((rg: any) => rg.role_objid); + authFilter = ` + AND EXISTS ( + SELECT 1 + FROM rel_menu_auth rma + WHERE rma.menu_objid = MENU.OBJID + AND rma.auth_objid = ANY($${paramIndex}) + AND rma.read_yn = 'Y' + ) + `; + unionFilter = ` + AND EXISTS ( + SELECT 1 + FROM rel_menu_auth rma + WHERE rma.menu_objid = MENU_SUB.OBJID + AND rma.auth_objid = ANY($${paramIndex}) + AND rma.read_yn = 'Y' + ) + `; + queryParams.push(roleObjids); + paramIndex++; + logger.info( + `✅ 권한 그룹 기반 메뉴 필터링 적용: ${roleObjids.length}개 그룹` + ); + } else { + // 권한 그룹이 없는 경우: 메뉴 없음 + logger.warn( + `⚠️ 사용자 ${userId}는 권한 그룹이 없어 메뉴가 표시되지 않습니다.` + ); + return []; + } } - // 3. 회사별 필터링 조건 생성 + // 2. 회사별 필터링 조건 생성 let companyFilter = ""; if (userType === "SUPER_ADMIN" && userCompanyCode === "*") { // SUPER_ADMIN: 공통 메뉴만 (company_code = '*') - logger.info("✅ 좌측 사이드바 (SUPER_ADMIN): 공통 메뉴만 표시"); companyFilter = `AND MENU.COMPANY_CODE = '*'`; } else { // COMPANY_ADMIN/USER: 자기 회사 메뉴만 logger.info( - `✅ 좌측 사이드바 (COMPANY_ADMIN): 회사 ${userCompanyCode} 메뉴만 표시` + `✅ 좌측 사이드바 (COMPANY_ADMIN/USER): 회사 ${userCompanyCode} 메뉴만 표시` ); companyFilter = `AND MENU.COMPANY_CODE = $${paramIndex}`; queryParams.push(userCompanyCode); @@ -480,7 +515,7 @@ export class AdminService { FROM MENU_INFO MENU_SUB JOIN V_MENU ON MENU_SUB.PARENT_OBJ_ID = V_MENU.OBJID WHERE MENU_SUB.STATUS = 'active' - ${authFilter.replace(/MENU\.OBJID/g, "MENU_SUB.OBJID")} + ${unionFilter} ) SELECT LEVEL AS LEV, diff --git a/backend-node/src/services/commonCodeService.ts b/backend-node/src/services/commonCodeService.ts index 69c8cba1..a823532d 100644 --- a/backend-node/src/services/commonCodeService.ts +++ b/backend-node/src/services/commonCodeService.ts @@ -8,6 +8,7 @@ export interface CodeCategory { description?: string | null; sort_order: number; is_active: string; + company_code: string; // 추가 created_date?: Date | null; created_by?: string | null; updated_date?: Date | null; @@ -22,6 +23,7 @@ export interface CodeInfo { description?: string | null; sort_order: number; is_active: string; + company_code: string; // 추가 created_date?: Date | null; created_by?: string | null; updated_date?: Date | null; @@ -64,7 +66,7 @@ export class CommonCodeService { /** * 카테고리 목록 조회 */ - async getCategories(params: GetCategoriesParams) { + async getCategories(params: GetCategoriesParams, userCompanyCode?: string) { try { const { search, isActive, page = 1, size = 20 } = params; @@ -72,6 +74,17 @@ export class CommonCodeService { const values: any[] = []; let paramIndex = 1; + // 회사별 필터링 (최고 관리자가 아닌 경우) + if (userCompanyCode && userCompanyCode !== "*") { + whereConditions.push(`company_code = $${paramIndex}`); + values.push(userCompanyCode); + paramIndex++; + logger.info(`회사별 코드 카테고리 필터링: ${userCompanyCode}`); + } else if (userCompanyCode === "*") { + // 최고 관리자는 모든 데이터 조회 가능 + logger.info(`최고 관리자: 모든 코드 카테고리 조회`); + } + if (search) { whereConditions.push( `(category_name ILIKE $${paramIndex} OR category_code ILIKE $${paramIndex})` @@ -110,7 +123,7 @@ export class CommonCodeService { const total = parseInt(countResult?.count || "0"); logger.info( - `카테고리 조회 완료: ${categories.length}개, 전체: ${total}개` + `카테고리 조회 완료: ${categories.length}개, 전체: ${total}개 (회사: ${userCompanyCode || "전체"})` ); return { @@ -126,7 +139,11 @@ export class CommonCodeService { /** * 카테고리별 코드 목록 조회 */ - async getCodes(categoryCode: string, params: GetCodesParams) { + async getCodes( + categoryCode: string, + params: GetCodesParams, + userCompanyCode?: string + ) { try { const { search, isActive, page = 1, size = 20 } = params; @@ -134,6 +151,16 @@ export class CommonCodeService { const values: any[] = [categoryCode]; let paramIndex = 2; + // 회사별 필터링 (최고 관리자가 아닌 경우) + if (userCompanyCode && userCompanyCode !== "*") { + whereConditions.push(`company_code = $${paramIndex}`); + values.push(userCompanyCode); + paramIndex++; + logger.info(`회사별 코드 필터링: ${userCompanyCode}`); + } else if (userCompanyCode === "*") { + logger.info(`최고 관리자: 모든 코드 조회`); + } + if (search) { whereConditions.push( `(code_name ILIKE $${paramIndex} OR code_value ILIKE $${paramIndex})` @@ -169,7 +196,7 @@ export class CommonCodeService { const total = parseInt(countResult?.count || "0"); logger.info( - `코드 조회 완료: ${categoryCode} - ${codes.length}개, 전체: ${total}개` + `코드 조회 완료: ${categoryCode} - ${codes.length}개, 전체: ${total}개 (회사: ${userCompanyCode || "전체"})` ); return { data: codes, total }; @@ -182,13 +209,17 @@ export class CommonCodeService { /** * 카테고리 생성 */ - async createCategory(data: CreateCategoryData, createdBy: string) { + async createCategory( + data: CreateCategoryData, + createdBy: string, + companyCode: string + ) { try { const category = await queryOne( `INSERT INTO code_category (category_code, category_name, category_name_eng, description, sort_order, - is_active, created_by, updated_by, created_date, updated_date) - VALUES ($1, $2, $3, $4, $5, 'Y', $6, $7, NOW(), NOW()) + is_active, company_code, created_by, updated_by, created_date, updated_date) + VALUES ($1, $2, $3, $4, $5, 'Y', $6, $7, $8, NOW(), NOW()) RETURNING *`, [ data.categoryCode, @@ -196,12 +227,15 @@ export class CommonCodeService { data.categoryNameEng || null, data.description || null, data.sortOrder || 0, + companyCode, createdBy, createdBy, ] ); - logger.info(`카테고리 생성 완료: ${data.categoryCode}`); + logger.info( + `카테고리 생성 완료: ${data.categoryCode} (회사: ${companyCode})` + ); return category; } catch (error) { logger.error("카테고리 생성 중 오류:", error); @@ -215,11 +249,12 @@ export class CommonCodeService { async updateCategory( categoryCode: string, data: Partial, - updatedBy: string + updatedBy: string, + companyCode?: string ) { try { // 디버깅: 받은 데이터 로그 - logger.info(`카테고리 수정 데이터:`, { categoryCode, data }); + logger.info(`카테고리 수정 데이터:`, { categoryCode, data, companyCode }); // 동적 UPDATE 쿼리 생성 const updateFields: string[] = [ @@ -256,15 +291,28 @@ export class CommonCodeService { values.push(activeValue); } + // WHERE 절 구성 + let whereClause = `WHERE category_code = $${paramIndex}`; + values.push(categoryCode); + + // 회사 필터링 (최고 관리자가 아닌 경우) + if (companyCode && companyCode !== "*") { + paramIndex++; + whereClause += ` AND company_code = $${paramIndex}`; + values.push(companyCode); + } + const category = await queryOne( `UPDATE code_category SET ${updateFields.join(", ")} - WHERE category_code = $${paramIndex} + ${whereClause} RETURNING *`, - [...values, categoryCode] + values ); - logger.info(`카테고리 수정 완료: ${categoryCode}`); + logger.info( + `카테고리 수정 완료: ${categoryCode} (회사: ${companyCode || "전체"})` + ); return category; } catch (error) { logger.error(`카테고리 수정 중 오류 (${categoryCode}):`, error); @@ -275,13 +323,22 @@ export class CommonCodeService { /** * 카테고리 삭제 */ - async deleteCategory(categoryCode: string) { + async deleteCategory(categoryCode: string, companyCode?: string) { try { - await query(`DELETE FROM code_category WHERE category_code = $1`, [ - categoryCode, - ]); + let sql = `DELETE FROM code_category WHERE category_code = $1`; + const values: any[] = [categoryCode]; - logger.info(`카테고리 삭제 완료: ${categoryCode}`); + // 회사 필터링 (최고 관리자가 아닌 경우) + if (companyCode && companyCode !== "*") { + sql += ` AND company_code = $2`; + values.push(companyCode); + } + + await query(sql, values); + + logger.info( + `카테고리 삭제 완료: ${categoryCode} (회사: ${companyCode || "전체"})` + ); } catch (error) { logger.error(`카테고리 삭제 중 오류 (${categoryCode}):`, error); throw error; @@ -294,14 +351,15 @@ export class CommonCodeService { async createCode( categoryCode: string, data: CreateCodeData, - createdBy: string + createdBy: string, + companyCode: string ) { try { const code = await queryOne( `INSERT INTO code_info (code_category, code_value, code_name, code_name_eng, description, sort_order, - is_active, created_by, updated_by, created_date, updated_date) - VALUES ($1, $2, $3, $4, $5, $6, 'Y', $7, $8, NOW(), NOW()) + is_active, company_code, created_by, updated_by, created_date, updated_date) + VALUES ($1, $2, $3, $4, $5, $6, 'Y', $7, $8, $9, NOW(), NOW()) RETURNING *`, [ categoryCode, @@ -310,12 +368,15 @@ export class CommonCodeService { data.codeNameEng || null, data.description || null, data.sortOrder || 0, + companyCode, createdBy, createdBy, ] ); - logger.info(`코드 생성 완료: ${categoryCode}.${data.codeValue}`); + logger.info( + `코드 생성 완료: ${categoryCode}.${data.codeValue} (회사: ${companyCode})` + ); return code; } catch (error) { logger.error( @@ -333,11 +394,17 @@ export class CommonCodeService { categoryCode: string, codeValue: string, data: Partial, - updatedBy: string + updatedBy: string, + companyCode?: string ) { try { // 디버깅: 받은 데이터 로그 - logger.info(`코드 수정 데이터:`, { categoryCode, codeValue, data }); + logger.info(`코드 수정 데이터:`, { + categoryCode, + codeValue, + data, + companyCode, + }); // 동적 UPDATE 쿼리 생성 const updateFields: string[] = [ @@ -374,15 +441,28 @@ export class CommonCodeService { values.push(activeValue); } + // WHERE 절 구성 + let whereClause = `WHERE code_category = $${paramIndex++} AND code_value = $${paramIndex}`; + values.push(categoryCode, codeValue); + + // 회사 필터링 (최고 관리자가 아닌 경우) + if (companyCode && companyCode !== "*") { + paramIndex++; + whereClause += ` AND company_code = $${paramIndex}`; + values.push(companyCode); + } + const code = await queryOne( `UPDATE code_info SET ${updateFields.join(", ")} - WHERE code_category = $${paramIndex++} AND code_value = $${paramIndex} + ${whereClause} RETURNING *`, - [...values, categoryCode, codeValue] + values ); - logger.info(`코드 수정 완료: ${categoryCode}.${codeValue}`); + logger.info( + `코드 수정 완료: ${categoryCode}.${codeValue} (회사: ${companyCode || "전체"})` + ); return code; } catch (error) { logger.error(`코드 수정 중 오류 (${categoryCode}.${codeValue}):`, error); @@ -393,14 +473,26 @@ export class CommonCodeService { /** * 코드 삭제 */ - async deleteCode(categoryCode: string, codeValue: string) { + async deleteCode( + categoryCode: string, + codeValue: string, + companyCode?: string + ) { try { - await query( - `DELETE FROM code_info WHERE code_category = $1 AND code_value = $2`, - [categoryCode, codeValue] - ); + let sql = `DELETE FROM code_info WHERE code_category = $1 AND code_value = $2`; + const values: any[] = [categoryCode, codeValue]; - logger.info(`코드 삭제 완료: ${categoryCode}.${codeValue}`); + // 회사 필터링 (최고 관리자가 아닌 경우) + if (companyCode && companyCode !== "*") { + sql += ` AND company_code = $3`; + values.push(companyCode); + } + + await query(sql, values); + + logger.info( + `코드 삭제 완료: ${categoryCode}.${codeValue} (회사: ${companyCode || "전체"})` + ); } catch (error) { logger.error(`코드 삭제 중 오류 (${categoryCode}.${codeValue}):`, error); throw error; @@ -410,20 +502,30 @@ export class CommonCodeService { /** * 카테고리별 옵션 조회 (화면관리용) */ - async getCodeOptions(categoryCode: string) { + async getCodeOptions(categoryCode: string, userCompanyCode?: string) { try { + let sql = `SELECT code_value, code_name, code_name_eng, sort_order + FROM code_info + WHERE code_category = $1 AND is_active = 'Y'`; + const values: any[] = [categoryCode]; + + // 회사별 필터링 (최고 관리자가 아닌 경우) + if (userCompanyCode && userCompanyCode !== "*") { + sql += ` AND company_code = $2`; + values.push(userCompanyCode); + logger.info(`회사별 코드 옵션 필터링: ${userCompanyCode}`); + } else if (userCompanyCode === "*") { + logger.info(`최고 관리자: 모든 코드 옵션 조회`); + } + + sql += ` ORDER BY sort_order ASC, code_value ASC`; + const codes = await query<{ code_value: string; code_name: string; code_name_eng: string | null; sort_order: number; - }>( - `SELECT code_value, code_name, code_name_eng, sort_order - FROM code_info - WHERE code_category = $1 AND is_active = 'Y' - ORDER BY sort_order ASC, code_value ASC`, - [categoryCode] - ); + }>(sql, values); const options = codes.map((code) => ({ value: code.code_value, @@ -431,7 +533,9 @@ export class CommonCodeService { labelEng: code.code_name_eng, })); - logger.info(`코드 옵션 조회 완료: ${categoryCode} - ${options.length}개`); + logger.info( + `코드 옵션 조회 완료: ${categoryCode} - ${options.length}개 (회사: ${userCompanyCode || "전체"})` + ); return options; } catch (error) { logger.error(`코드 옵션 조회 중 오류 (${categoryCode}):`, error); diff --git a/backend-node/src/services/ddlExecutionService.ts b/backend-node/src/services/ddlExecutionService.ts index c28ff7c7..37659bcf 100644 --- a/backend-node/src/services/ddlExecutionService.ts +++ b/backend-node/src/services/ddlExecutionService.ts @@ -759,6 +759,124 @@ CREATE TABLE "${tableName}" (${baseColumns}, } } + /** + * 테이블 삭제 (DROP TABLE) + */ + async dropTable( + tableName: string, + userCompanyCode: string, + userId: string + ): Promise { + // DDL 실행 시작 로그 + await DDLAuditLogger.logDDLStart( + userId, + userCompanyCode, + "DROP_TABLE", + tableName, + {} + ); + + try { + // 1. 권한 검증 (최고 관리자만 가능) + this.validateSuperAdminPermission(userCompanyCode); + + // 2. 테이블 존재 여부 확인 + const tableExists = await this.checkTableExists(tableName); + if (!tableExists) { + const errorMessage = `테이블 '${tableName}'이 존재하지 않습니다.`; + + await DDLAuditLogger.logDDLExecution( + userId, + userCompanyCode, + "DROP_TABLE", + tableName, + "TABLE_NOT_FOUND", + false, + errorMessage + ); + + return { + success: false, + message: errorMessage, + error: { + code: "TABLE_NOT_FOUND", + details: errorMessage, + }, + }; + } + + // 3. DDL 쿼리 생성 + const ddlQuery = `DROP TABLE IF EXISTS "${tableName}" CASCADE`; + + // 4. 트랜잭션으로 안전하게 실행 + await transaction(async (client) => { + // 4-1. 테이블 삭제 + await client.query(ddlQuery); + + // 4-2. 관련 메타데이터 삭제 + await client.query(`DELETE FROM column_labels WHERE table_name = $1`, [ + tableName, + ]); + await client.query(`DELETE FROM table_labels WHERE table_name = $1`, [ + tableName, + ]); + }); + + // 5. 성공 로그 기록 + await DDLAuditLogger.logDDLExecution( + userId, + userCompanyCode, + "DROP_TABLE", + tableName, + ddlQuery, + true + ); + + logger.info("테이블 삭제 성공", { + tableName, + userId, + }); + + // 테이블 삭제 후 관련 캐시 무효화 + this.invalidateTableCache(tableName); + + return { + success: true, + message: `테이블 '${tableName}'이 성공적으로 삭제되었습니다.`, + executedQuery: ddlQuery, + }; + } catch (error) { + const errorMessage = `테이블 삭제 실패: ${(error as Error).message}`; + + // 실패 로그 기록 + await DDLAuditLogger.logDDLExecution( + userId, + userCompanyCode, + "DROP_TABLE", + tableName, + `FAILED: ${(error as Error).message}`, + false, + errorMessage + ); + + logger.error("테이블 삭제 실패:", { + tableName, + userId, + error: (error as Error).message, + stack: (error as Error).stack, + }); + + return { + success: false, + message: errorMessage, + error: { + code: "EXECUTION_FAILED", + details: (error as Error).message, + }, + }; + } + } + /** * 테이블 관련 캐시 무효화 * DDL 작업 후 호출하여 캐시된 데이터를 클리어 diff --git a/frontend/app/(main)/admin/tableMng/page.tsx b/frontend/app/(main)/admin/tableMng/page.tsx index a85a033f..8d6d33d0 100644 --- a/frontend/app/(main)/admin/tableMng/page.tsx +++ b/frontend/app/(main)/admin/tableMng/page.tsx @@ -5,7 +5,7 @@ import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Search, Database, RefreshCw, Settings, Plus, Activity } from "lucide-react"; +import { Search, Database, RefreshCw, Settings, Plus, Activity, Trash2 } from "lucide-react"; import { LoadingSpinner } from "@/components/common/LoadingSpinner"; import { toast } from "sonner"; import { useMultiLang } from "@/hooks/useMultiLang"; @@ -15,11 +15,20 @@ import { INPUT_TYPE_OPTIONS } from "@/types/input-types"; import { apiClient } from "@/lib/api/client"; import { commonCodeApi } from "@/lib/api/commonCode"; import { entityJoinApi, ReferenceTableColumn } from "@/lib/api/entityJoin"; +import { ddlApi } from "@/lib/api/ddl"; import { CreateTableModal } from "@/components/admin/CreateTableModal"; import { AddColumnModal } from "@/components/admin/AddColumnModal"; import { DDLLogViewer } from "@/components/admin/DDLLogViewer"; import { TableLogViewer } from "@/components/admin/TableLogViewer"; import { ScrollToTop } from "@/components/common/ScrollToTop"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; interface TableInfo { tableName: string; @@ -79,6 +88,11 @@ export default function TableManagementPage() { const [logViewerOpen, setLogViewerOpen] = useState(false); const [logViewerTableName, setLogViewerTableName] = useState(""); + // 테이블 삭제 확인 다이얼로그 상태 + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [tableToDelete, setTableToDelete] = useState(""); + const [isDeleting, setIsDeleting] = useState(false); + // 최고 관리자 여부 확인 (회사코드가 "*"인 경우) const isSuperAdmin = user?.companyCode === "*"; @@ -523,7 +537,7 @@ export default function TableManagementPage() { useEffect(() => { if (columns.length > 0) { const entityColumns = columns.filter( - (col) => col.webType === "entity" && col.referenceTable && col.referenceTable !== "none", + (col) => col.inputType === "entity" && col.referenceTable && col.referenceTable !== "none", ); entityColumns.forEach((col) => { @@ -543,6 +557,43 @@ export default function TableManagementPage() { } }, [selectedTable, columns.length, totalColumns, columnsLoading, pageSize, loadColumnTypes]); + // 테이블 삭제 확인 + const handleDeleteTableClick = (tableName: string) => { + setTableToDelete(tableName); + setDeleteDialogOpen(true); + }; + + // 테이블 삭제 실행 + const handleDeleteTable = async () => { + if (!tableToDelete) return; + + setIsDeleting(true); + try { + const result = await ddlApi.dropTable(tableToDelete); + + if (result.success) { + toast.success(`테이블 '${tableToDelete}'이 성공적으로 삭제되었습니다.`); + + // 삭제된 테이블이 선택된 테이블이었다면 선택 해제 + if (selectedTable === tableToDelete) { + setSelectedTable(null); + setColumns([]); + } + + // 테이블 목록 새로고침 + await loadTables(); + } else { + toast.error(result.message || "테이블 삭제에 실패했습니다."); + } + } catch (error: any) { + toast.error(error?.response?.data?.message || "테이블 삭제 중 오류가 발생했습니다."); + } finally { + setIsDeleting(false); + setDeleteDialogOpen(false); + setTableToDelete(""); + } + }; + return (
@@ -656,21 +707,40 @@ export default function TableManagementPage() { .map((table) => (
handleTableSelect(table.tableName)} > -

{table.displayName || table.tableName}

-

- {table.description || getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_DESCRIPTION, "설명 없음")} -

-
- 컬럼 - - {table.columnCount} - +
handleTableSelect(table.tableName)}> +

{table.displayName || table.tableName}

+

+ {table.description || getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_DESCRIPTION, "설명 없음")} +

+
+ 컬럼 + + {table.columnCount} + +
+ + {/* 삭제 버튼 (최고 관리자만) */} + {isSuperAdmin && ( +
+ +
+ )}
)) )} @@ -1000,6 +1070,57 @@ export default function TableManagementPage() { {/* 테이블 로그 뷰어 */} + + {/* 테이블 삭제 확인 다이얼로그 */} + + + + 테이블 삭제 확인 + + 정말로 테이블을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다. + + + +
+
+

경고

+

+ 테이블 {tableToDelete}과 모든 데이터가 영구적으로 + 삭제됩니다. +

+
+
+ + + + + +
+
)} diff --git a/frontend/components/screen/widgets/FlowWidget.tsx b/frontend/components/screen/widgets/FlowWidget.tsx index de401bea..69900000 100644 --- a/frontend/components/screen/widgets/FlowWidget.tsx +++ b/frontend/components/screen/widgets/FlowWidget.tsx @@ -4,28 +4,20 @@ import React, { useEffect, useState } from "react"; import { FlowComponent } from "@/types/screen-management"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { AlertCircle, Loader2, ChevronUp, History } from "lucide-react"; +import { AlertCircle, Loader2, ChevronUp } from "lucide-react"; import { getFlowById, getAllStepCounts, getStepDataList, - getFlowAuditLogs, getFlowSteps, getFlowConnections, + getStepColumnLabels, } from "@/lib/api/flow"; -import type { FlowDefinition, FlowStep, FlowAuditLog } from "@/types/flow"; +import type { FlowDefinition, FlowStep } from "@/types/flow"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Checkbox } from "@/components/ui/checkbox"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { toast } from "sonner"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; import { Pagination, PaginationContent, @@ -68,6 +60,7 @@ export function FlowWidget({ const [stepDataColumns, setStepDataColumns] = useState([]); const [stepDataLoading, setStepDataLoading] = useState(false); const [selectedRows, setSelectedRows] = useState>(new Set()); + const [columnLabels, setColumnLabels] = useState>({}); // 컬럼명 -> 라벨 매핑 /** * 🆕 컬럼 표시 결정 함수 @@ -93,13 +86,6 @@ export function FlowWidget({ const [stepDataPage, setStepDataPage] = useState(1); const [stepDataPageSize, setStepDataPageSize] = useState(10); - // 오딧 로그 상태 - const [auditLogs, setAuditLogs] = useState([]); - const [auditLogsLoading, setAuditLogsLoading] = useState(false); - const [showAuditLogs, setShowAuditLogs] = useState(false); - const [auditPage, setAuditPage] = useState(1); - const [auditPageSize] = useState(10); - // componentConfig에서 플로우 설정 추출 (DynamicComponentRenderer에서 전달됨) const config = (component as any).componentConfig || (component as any).config || {}; const flowId = config.flowId || component.flowId; @@ -139,6 +125,12 @@ export function FlowWidget({ if (selectedStepId) { setStepDataLoading(true); + // 컬럼 라벨 조회 + const labelsResponse = await getStepColumnLabels(flowId, selectedStepId); + if (labelsResponse.success && labelsResponse.data) { + setColumnLabels(labelsResponse.data); + } + const response = await getStepDataList(flowId, selectedStepId, 1, 100); if (!response.success) { @@ -226,6 +218,12 @@ export function FlowWidget({ // 첫 번째 스텝의 데이터 로드 try { + // 컬럼 라벨 조회 + const labelsResponse = await getStepColumnLabels(flowId!, firstStep.id); + if (labelsResponse.success && labelsResponse.data) { + setColumnLabels(labelsResponse.data); + } + const response = await getStepDataList(flowId!, firstStep.id, 1, 100); if (response.success) { const rows = response.data?.records || []; @@ -297,6 +295,15 @@ export function FlowWidget({ onSelectedDataChange?.([], stepId); try { + // 컬럼 라벨 조회 + const labelsResponse = await getStepColumnLabels(flowId!, stepId); + if (labelsResponse.success && labelsResponse.data) { + setColumnLabels(labelsResponse.data); + } else { + setColumnLabels({}); + } + + // 데이터 조회 const response = await getStepDataList(flowId!, stepId, 1, 100); if (!response.success) { @@ -359,35 +366,6 @@ export function FlowWidget({ onSelectedDataChange?.(selectedData, selectedStepId); }; - // 오딧 로그 로드 - const loadAuditLogs = async () => { - if (!flowId) return; - - try { - setAuditLogsLoading(true); - const response = await getFlowAuditLogs(flowId, 100); // 최근 100개 - if (response.success && response.data) { - setAuditLogs(response.data); - } - } catch (err: any) { - console.error("Failed to load audit logs:", err); - toast.error("이력 조회 중 오류가 발생했습니다"); - } finally { - setAuditLogsLoading(false); - } - }; - - // 오딧 로그 모달 열기 - const handleOpenAuditLogs = () => { - setShowAuditLogs(true); - setAuditPage(1); // 페이지 초기화 - loadAuditLogs(); - }; - - // 페이지네이션된 오딧 로그 - const paginatedAuditLogs = auditLogs.slice((auditPage - 1) * auditPageSize, auditPage * auditPageSize); - const totalAuditPages = Math.ceil(auditLogs.length / auditPageSize); - // 🆕 페이지네이션된 스텝 데이터 const paginatedStepData = stepData.slice((stepDataPage - 1) * stepDataPageSize, stepDataPage * stepDataPageSize); const totalStepDataPages = Math.ceil(stepData.length / stepDataPageSize); @@ -438,188 +416,6 @@ export function FlowWidget({

{flowData.name}

- - {/* 오딧 로그 버튼 */} - - - - - - - 플로우 변경 이력 - 데이터 이동 및 상태 변경 기록 (총 {auditLogs.length}건) - - - {auditLogsLoading ? ( -
- - 이력 로딩 중... -
- ) : auditLogs.length === 0 ? ( -
변경 이력이 없습니다
- ) : ( -
- {/* 테이블 */} -
-
- - - - 변경일시 - 타입 - 출발 단계 - 도착 단계 - 데이터 ID - 상태 변경 - 변경자 - DB 연결 - 테이블 - - - - {paginatedAuditLogs.map((log) => { - const fromStep = steps.find((s) => s.id === log.fromStepId); - const toStep = steps.find((s) => s.id === log.toStepId); - - return ( - - - {new Date(log.changedAt).toLocaleString("ko-KR", { - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - })} - - - - {log.moveType === "status" - ? "상태" - : log.moveType === "table" - ? "테이블" - : "하이브리드"} - - - - {fromStep?.stepName || `Step ${log.fromStepId}`} - - - {toStep?.stepName || `Step ${log.toStepId}`} - - - {log.sourceDataId || "-"} - {log.targetDataId && log.targetDataId !== log.sourceDataId && ( - <> -
→ {log.targetDataId} - - )} -
- - {log.statusFrom && log.statusTo ? ( - - {log.statusFrom} -
→ {log.statusTo} -
- ) : ( - - - )} -
- {log.changedBy} - - {log.dbConnectionName ? ( - - {log.dbConnectionName} - - ) : ( - - - )} - - - {log.sourceTable || "-"} - {log.targetTable && log.targetTable !== log.sourceTable && ( - <> -
→ {log.targetTable} - - )} -
-
- ); - })} -
-
-
-
- - {/* 페이지네이션 */} - {totalAuditPages > 1 && ( -
-
- {(auditPage - 1) * auditPageSize + 1}-{Math.min(auditPage * auditPageSize, auditLogs.length)} /{" "} - {auditLogs.length}건 -
- - - - setAuditPage((p) => Math.max(1, p - 1))} - className={auditPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"} - /> - - - {Array.from({ length: totalAuditPages }, (_, i) => i + 1) - .filter((page) => { - // 현재 페이지 주변만 표시 - return ( - page === 1 || - page === totalAuditPages || - (page >= auditPage - 1 && page <= auditPage + 1) - ); - }) - .map((page, idx, arr) => ( - - {idx > 0 && arr[idx - 1] !== page - 1 && ( - - ... - - )} - - setAuditPage(page)} - isActive={auditPage === page} - className="cursor-pointer" - > - {page} - - - - ))} - - - setAuditPage((p) => Math.min(totalAuditPages, p + 1))} - className={ - auditPage === totalAuditPages ? "pointer-events-none opacity-50" : "cursor-pointer" - } - /> - - - -
- )} -
- )} -
-
{flowData.description && ( @@ -758,7 +554,7 @@ export function FlowWidget({
{stepDataColumns.map((col) => (
- {col}: + {columnLabels[col] || col}: {row[col] !== null && row[col] !== undefined ? ( String(row[col]) @@ -793,7 +589,7 @@ export function FlowWidget({ key={col} className="bg-background sticky top-0 z-10 border-b px-3 py-2 text-xs font-semibold whitespace-nowrap shadow-[0_1px_0_0_rgb(0,0,0,0.1)] sm:text-sm" > - {col} + {columnLabels[col] || col} ))} diff --git a/frontend/lib/api/ddl.ts b/frontend/lib/api/ddl.ts index 442b721a..0c372b64 100644 --- a/frontend/lib/api/ddl.ts +++ b/frontend/lib/api/ddl.ts @@ -29,6 +29,14 @@ export const ddlApi = { return response.data; }, + /** + * 테이블 삭제 (DROP TABLE) + */ + dropTable: async (tableName: string): Promise => { + const response = await apiClient.delete(`/ddl/tables/${tableName}`); + return response.data; + }, + /** * 테이블 생성 사전 검증 (실제 생성하지 않고 검증만) */ diff --git a/frontend/lib/api/flow.ts b/frontend/lib/api/flow.ts index 1da23ad7..0a917692 100644 --- a/frontend/lib/api/flow.ts +++ b/frontend/lib/api/flow.ts @@ -384,6 +384,28 @@ export async function getStepDataList( } } +/** + * 플로우 스텝의 컬럼 라벨 조회 + */ +export async function getStepColumnLabels( + flowId: number, + stepId: number, +): Promise>> { + try { + const response = await fetch(`${API_BASE}/flow/${flowId}/step/${stepId}/column-labels`, { + headers: getAuthHeaders(), + credentials: "include", + }); + + return await response.json(); + } catch (error: any) { + return { + success: false, + error: error.message, + }; + } +} + /** * 모든 단계의 데이터 카운트 조회 */