; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
This commit is contained in:
leeheejin 2025-10-28 10:08:40 +09:00
commit d5e72ce901
13 changed files with 1273 additions and 370 deletions

View File

@ -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 = "*"는 공통 데이터가 아닌 최고 관리자 전용 데이터입니다!**

View File

@ -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,6 +90,7 @@ export class CommonCodeController {
sortOrder: code.sort_order,
isActive: code.is_active,
useYn: code.is_active,
companyCode: code.company_code, // 추가
// 기존 필드명도 유지 (하위 호환성)
code_category: code.code_category,
@ -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<CreateCategoryData> = 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<CreateCodeData> = 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,

View File

@ -383,6 +383,79 @@ export class DDLController {
}
}
/**
* DELETE /api/ddl/tables/:tableName - ( )
*/
static async dropTable(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
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
*/

View File

@ -551,6 +551,76 @@ export class FlowController {
}
};
/**
*
*/
getStepColumnLabels = async (req: Request, res: Response): Promise<void> => {
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<string, string> = {};
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",
});
}
};
/**
*
*/

View File

@ -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",
},

View File

@ -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);
// ==================== 데이터 이동 ====================

View File

@ -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<any>(
`
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<any>(
`
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,

View File

@ -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<CodeCategory>(
`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<CreateCategoryData>,
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<CodeCategory>(
`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<CodeInfo>(
`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<CreateCodeData>,
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<CodeInfo>(
`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);

View File

@ -759,6 +759,124 @@ CREATE TABLE "${tableName}" (${baseColumns},
}
}
/**
* (DROP TABLE)
*/
async dropTable(
tableName: string,
userCompanyCode: string,
userId: string
): Promise<DDLExecutionResult> {
// 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

View File

@ -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<string>("");
// 테이블 삭제 확인 다이얼로그 상태
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [tableToDelete, setTableToDelete] = useState<string>("");
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 (
<div className="bg-background flex min-h-screen flex-col">
<div className="space-y-6 p-6">
@ -656,21 +707,40 @@ export default function TableManagementPage() {
.map((table) => (
<div
key={table.tableName}
className={`bg-card cursor-pointer rounded-lg border p-4 shadow-sm transition-all ${
className={`bg-card rounded-lg border p-4 shadow-sm transition-all ${
selectedTable === table.tableName ? "shadow-md" : "hover:shadow-md"
}`}
onClick={() => handleTableSelect(table.tableName)}
>
<h4 className="text-sm font-semibold">{table.displayName || table.tableName}</h4>
<p className="text-muted-foreground mt-1 text-xs">
{table.description || getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_DESCRIPTION, "설명 없음")}
</p>
<div className="mt-2 flex items-center justify-between border-t pt-2">
<span className="text-muted-foreground text-xs"></span>
<Badge variant="secondary" className="text-xs">
{table.columnCount}
</Badge>
<div className="cursor-pointer" onClick={() => handleTableSelect(table.tableName)}>
<h4 className="text-sm font-semibold">{table.displayName || table.tableName}</h4>
<p className="text-muted-foreground mt-1 text-xs">
{table.description || getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_DESCRIPTION, "설명 없음")}
</p>
<div className="mt-2 flex items-center justify-between border-t pt-2">
<span className="text-muted-foreground text-xs"></span>
<Badge variant="secondary" className="text-xs">
{table.columnCount}
</Badge>
</div>
</div>
{/* 삭제 버튼 (최고 관리자만) */}
{isSuperAdmin && (
<div className="mt-2 border-t pt-2">
<Button
variant="destructive"
size="sm"
className="h-8 w-full gap-2 text-xs"
onClick={(e) => {
e.stopPropagation();
handleDeleteTableClick(table.tableName);
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
)}
</div>
))
)}
@ -1000,6 +1070,57 @@ export default function TableManagementPage() {
{/* 테이블 로그 뷰어 */}
<TableLogViewer tableName={logViewerTableName} open={logViewerOpen} onOpenChange={setLogViewerOpen} />
{/* 테이블 삭제 확인 다이얼로그 */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
? .
</DialogDescription>
</DialogHeader>
<div className="space-y-3 sm:space-y-4">
<div className="border-destructive/50 bg-destructive/10 rounded-lg border p-4">
<p className="text-destructive text-sm font-semibold"></p>
<p className="text-destructive/80 mt-1.5 text-sm">
<span className="font-mono font-bold">{tableToDelete}</span>
.
</p>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setDeleteDialogOpen(false)}
disabled={isDeleting}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
variant="destructive"
onClick={handleDeleteTable}
disabled={isDeleting}
className="h-8 flex-1 gap-2 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{isDeleting ? (
<>
<RefreshCw className="h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Trash2 className="h-4 w-4" />
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)}

View File

@ -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<string[]>([]);
const [stepDataLoading, setStepDataLoading] = useState(false);
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
const [columnLabels, setColumnLabels] = useState<Record<string, string>>({}); // 컬럼명 -> 라벨 매핑
/**
* 🆕
@ -93,13 +86,6 @@ export function FlowWidget({
const [stepDataPage, setStepDataPage] = useState(1);
const [stepDataPageSize, setStepDataPageSize] = useState(10);
// 오딧 로그 상태
const [auditLogs, setAuditLogs] = useState<FlowAuditLog[]>([]);
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({
<div className="mb-3 flex-shrink-0 sm:mb-4">
<div className="flex items-center justify-center gap-2">
<h3 className="text-foreground text-base font-semibold sm:text-lg lg:text-xl">{flowData.name}</h3>
{/* 오딧 로그 버튼 */}
<Dialog open={showAuditLogs} onOpenChange={setShowAuditLogs}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" onClick={handleOpenAuditLogs} className="gap-1.5">
<History className="h-4 w-4" />
<span className="hidden sm:inline"> </span>
</Button>
</DialogTrigger>
<DialogContent className="max-h-[85vh] max-w-[95vw] sm:max-w-[1000px]">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> ( {auditLogs.length})</DialogDescription>
</DialogHeader>
{auditLogsLoading ? (
<div className="flex items-center justify-center p-8">
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
<span className="text-muted-foreground ml-2 text-sm"> ...</span>
</div>
) : auditLogs.length === 0 ? (
<div className="text-muted-foreground py-8 text-center text-sm"> </div>
) : (
<div className="space-y-4">
{/* 테이블 */}
<div className="bg-card overflow-hidden rounded-lg border">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="w-[140px]"></TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[120px]"> </TableHead>
<TableHead className="w-[120px]"> </TableHead>
<TableHead className="w-[100px]"> ID</TableHead>
<TableHead className="w-[140px]"> </TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[150px]">DB </TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginatedAuditLogs.map((log) => {
const fromStep = steps.find((s) => s.id === log.fromStepId);
const toStep = steps.find((s) => s.id === log.toStepId);
return (
<TableRow key={log.id} className="hover:bg-muted/50">
<TableCell className="font-mono text-xs">
{new Date(log.changedAt).toLocaleString("ko-KR", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})}
</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs">
{log.moveType === "status"
? "상태"
: log.moveType === "table"
? "테이블"
: "하이브리드"}
</Badge>
</TableCell>
<TableCell className="font-medium">
{fromStep?.stepName || `Step ${log.fromStepId}`}
</TableCell>
<TableCell className="font-medium">
{toStep?.stepName || `Step ${log.toStepId}`}
</TableCell>
<TableCell className="font-mono text-xs">
{log.sourceDataId || "-"}
{log.targetDataId && log.targetDataId !== log.sourceDataId && (
<>
<br /> {log.targetDataId}
</>
)}
</TableCell>
<TableCell className="text-xs">
{log.statusFrom && log.statusTo ? (
<span className="font-mono">
{log.statusFrom}
<br /> {log.statusTo}
</span>
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell className="text-xs">{log.changedBy}</TableCell>
<TableCell className="text-xs">
{log.dbConnectionName ? (
<span
className={
log.dbConnectionName === "내부 데이터베이스"
? "text-blue-600"
: "text-green-600"
}
>
{log.dbConnectionName}
</span>
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell className="text-xs">
{log.sourceTable || "-"}
{log.targetTable && log.targetTable !== log.sourceTable && (
<>
<br /> {log.targetTable}
</>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</div>
{/* 페이지네이션 */}
{totalAuditPages > 1 && (
<div className="flex items-center justify-between">
<div className="text-muted-foreground text-sm">
{(auditPage - 1) * auditPageSize + 1}-{Math.min(auditPage * auditPageSize, auditLogs.length)} /{" "}
{auditLogs.length}
</div>
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
onClick={() => setAuditPage((p) => Math.max(1, p - 1))}
className={auditPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}
/>
</PaginationItem>
{Array.from({ length: totalAuditPages }, (_, i) => i + 1)
.filter((page) => {
// 현재 페이지 주변만 표시
return (
page === 1 ||
page === totalAuditPages ||
(page >= auditPage - 1 && page <= auditPage + 1)
);
})
.map((page, idx, arr) => (
<React.Fragment key={page}>
{idx > 0 && arr[idx - 1] !== page - 1 && (
<PaginationItem>
<span className="text-muted-foreground px-2">...</span>
</PaginationItem>
)}
<PaginationItem>
<PaginationLink
onClick={() => setAuditPage(page)}
isActive={auditPage === page}
className="cursor-pointer"
>
{page}
</PaginationLink>
</PaginationItem>
</React.Fragment>
))}
<PaginationItem>
<PaginationNext
onClick={() => setAuditPage((p) => Math.min(totalAuditPages, p + 1))}
className={
auditPage === totalAuditPages ? "pointer-events-none opacity-50" : "cursor-pointer"
}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
)}
</div>
)}
</DialogContent>
</Dialog>
</div>
{flowData.description && (
@ -758,7 +554,7 @@ export function FlowWidget({
<div className="space-y-1.5">
{stepDataColumns.map((col) => (
<div key={col} className="flex justify-between gap-2 text-xs">
<span className="text-muted-foreground font-medium">{col}:</span>
<span className="text-muted-foreground font-medium">{columnLabels[col] || col}:</span>
<span className="text-foreground truncate">
{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}
</TableHead>
))}
</TableRow>

View File

@ -29,6 +29,14 @@ export const ddlApi = {
return response.data;
},
/**
* (DROP TABLE)
*/
dropTable: async (tableName: string): Promise<DDLExecutionResult> => {
const response = await apiClient.delete(`/ddl/tables/${tableName}`);
return response.data;
},
/**
* ( )
*/

View File

@ -384,6 +384,28 @@ export async function getStepDataList(
}
}
/**
*
*/
export async function getStepColumnLabels(
flowId: number,
stepId: number,
): Promise<ApiResponse<Record<string, string>>> {
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,
};
}
}
/**
*
*/