feat: Implement company code validation in flow management
- Enhanced the FlowController to include user company code validation for flow definitions, ensuring that users can only access and modify flows belonging to their company. - Updated the FlowDefinitionService to accept company code as a parameter for create, update, and delete operations, enforcing ownership checks. - Introduced sanitization methods in FlowConditionParser to prevent SQL injection for column and table names. - Modified the FlowDataMoveService to validate table names and column names during data movement operations, enhancing security. - Updated the frontend components to support batch data movement with proper validation and error handling.
This commit is contained in:
parent
e2d88f01e3
commit
fd5c61b12a
|
|
@ -144,8 +144,9 @@ export class FlowController {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const flowId = parseInt(id);
|
const flowId = parseInt(id);
|
||||||
|
const userCompanyCode = (req as any).user?.companyCode;
|
||||||
|
|
||||||
const definition = await this.flowDefinitionService.findById(flowId);
|
const definition = await this.flowDefinitionService.findById(flowId, userCompanyCode);
|
||||||
if (!definition) {
|
if (!definition) {
|
||||||
res.status(404).json({
|
res.status(404).json({
|
||||||
success: false,
|
success: false,
|
||||||
|
|
@ -182,12 +183,13 @@ export class FlowController {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const flowId = parseInt(id);
|
const flowId = parseInt(id);
|
||||||
const { name, description, isActive } = req.body;
|
const { name, description, isActive } = req.body;
|
||||||
|
const userCompanyCode = (req as any).user?.companyCode;
|
||||||
|
|
||||||
const flowDef = await this.flowDefinitionService.update(flowId, {
|
const flowDef = await this.flowDefinitionService.update(flowId, {
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
isActive,
|
isActive,
|
||||||
});
|
}, userCompanyCode);
|
||||||
|
|
||||||
if (!flowDef) {
|
if (!flowDef) {
|
||||||
res.status(404).json({
|
res.status(404).json({
|
||||||
|
|
@ -217,8 +219,9 @@ export class FlowController {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const flowId = parseInt(id);
|
const flowId = parseInt(id);
|
||||||
|
const userCompanyCode = (req as any).user?.companyCode;
|
||||||
|
|
||||||
const success = await this.flowDefinitionService.delete(flowId);
|
const success = await this.flowDefinitionService.delete(flowId, userCompanyCode);
|
||||||
|
|
||||||
if (!success) {
|
if (!success) {
|
||||||
res.status(404).json({
|
res.status(404).json({
|
||||||
|
|
@ -275,6 +278,7 @@ export class FlowController {
|
||||||
try {
|
try {
|
||||||
const { flowId } = req.params;
|
const { flowId } = req.params;
|
||||||
const flowDefinitionId = parseInt(flowId);
|
const flowDefinitionId = parseInt(flowId);
|
||||||
|
const userCompanyCode = (req as any).user?.companyCode;
|
||||||
const {
|
const {
|
||||||
stepName,
|
stepName,
|
||||||
stepOrder,
|
stepOrder,
|
||||||
|
|
@ -293,6 +297,16 @@ export class FlowController {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 플로우 소유권 검증
|
||||||
|
const flowDef = await this.flowDefinitionService.findById(flowDefinitionId, userCompanyCode);
|
||||||
|
if (!flowDef) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "Flow definition not found or access denied",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const step = await this.flowStepService.create({
|
const step = await this.flowStepService.create({
|
||||||
flowDefinitionId,
|
flowDefinitionId,
|
||||||
stepName,
|
stepName,
|
||||||
|
|
@ -324,6 +338,7 @@ export class FlowController {
|
||||||
try {
|
try {
|
||||||
const { stepId } = req.params;
|
const { stepId } = req.params;
|
||||||
const id = parseInt(stepId);
|
const id = parseInt(stepId);
|
||||||
|
const userCompanyCode = (req as any).user?.companyCode;
|
||||||
const {
|
const {
|
||||||
stepName,
|
stepName,
|
||||||
stepOrder,
|
stepOrder,
|
||||||
|
|
@ -342,6 +357,19 @@ export class FlowController {
|
||||||
displayConfig,
|
displayConfig,
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
|
// 스텝 소유권 검증: 스텝이 속한 플로우가 사용자 회사 소유인지 확인
|
||||||
|
const existingStep = await this.flowStepService.findById(id);
|
||||||
|
if (existingStep) {
|
||||||
|
const flowDef = await this.flowDefinitionService.findById(existingStep.flowDefinitionId, userCompanyCode);
|
||||||
|
if (!flowDef) {
|
||||||
|
res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: "Access denied: flow does not belong to your company",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const step = await this.flowStepService.update(id, {
|
const step = await this.flowStepService.update(id, {
|
||||||
stepName,
|
stepName,
|
||||||
stepOrder,
|
stepOrder,
|
||||||
|
|
@ -388,6 +416,20 @@ export class FlowController {
|
||||||
try {
|
try {
|
||||||
const { stepId } = req.params;
|
const { stepId } = req.params;
|
||||||
const id = parseInt(stepId);
|
const id = parseInt(stepId);
|
||||||
|
const userCompanyCode = (req as any).user?.companyCode;
|
||||||
|
|
||||||
|
// 스텝 소유권 검증
|
||||||
|
const existingStep = await this.flowStepService.findById(id);
|
||||||
|
if (existingStep) {
|
||||||
|
const flowDef = await this.flowDefinitionService.findById(existingStep.flowDefinitionId, userCompanyCode);
|
||||||
|
if (!flowDef) {
|
||||||
|
res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: "Access denied: flow does not belong to your company",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const success = await this.flowStepService.delete(id);
|
const success = await this.flowStepService.delete(id);
|
||||||
|
|
||||||
|
|
@ -446,6 +488,7 @@ export class FlowController {
|
||||||
createConnection = async (req: Request, res: Response): Promise<void> => {
|
createConnection = async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { flowDefinitionId, fromStepId, toStepId, label } = req.body;
|
const { flowDefinitionId, fromStepId, toStepId, label } = req.body;
|
||||||
|
const userCompanyCode = (req as any).user?.companyCode;
|
||||||
|
|
||||||
if (!flowDefinitionId || !fromStepId || !toStepId) {
|
if (!flowDefinitionId || !fromStepId || !toStepId) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
|
|
@ -455,6 +498,28 @@ export class FlowController {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 플로우 소유권 검증
|
||||||
|
const flowDef = await this.flowDefinitionService.findById(flowDefinitionId, userCompanyCode);
|
||||||
|
if (!flowDef) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "Flow definition not found or access denied",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// fromStepId, toStepId가 해당 flow에 속하는지 검증
|
||||||
|
const fromStep = await this.flowStepService.findById(fromStepId);
|
||||||
|
const toStep = await this.flowStepService.findById(toStepId);
|
||||||
|
if (!fromStep || fromStep.flowDefinitionId !== flowDefinitionId ||
|
||||||
|
!toStep || toStep.flowDefinitionId !== flowDefinitionId) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "fromStepId and toStepId must belong to the specified flow",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const connection = await this.flowConnectionService.create({
|
const connection = await this.flowConnectionService.create({
|
||||||
flowDefinitionId,
|
flowDefinitionId,
|
||||||
fromStepId,
|
fromStepId,
|
||||||
|
|
@ -482,6 +547,20 @@ export class FlowController {
|
||||||
try {
|
try {
|
||||||
const { connectionId } = req.params;
|
const { connectionId } = req.params;
|
||||||
const id = parseInt(connectionId);
|
const id = parseInt(connectionId);
|
||||||
|
const userCompanyCode = (req as any).user?.companyCode;
|
||||||
|
|
||||||
|
// 연결 소유권 검증
|
||||||
|
const existingConn = await this.flowConnectionService.findById(id);
|
||||||
|
if (existingConn) {
|
||||||
|
const flowDef = await this.flowDefinitionService.findById(existingConn.flowDefinitionId, userCompanyCode);
|
||||||
|
if (!flowDef) {
|
||||||
|
res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: "Access denied: flow does not belong to your company",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const success = await this.flowConnectionService.delete(id);
|
const success = await this.flowConnectionService.delete(id);
|
||||||
|
|
||||||
|
|
@ -670,23 +749,24 @@ export class FlowController {
|
||||||
*/
|
*/
|
||||||
moveData = async (req: Request, res: Response): Promise<void> => {
|
moveData = async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { flowId, recordId, toStepId, note } = req.body;
|
const { flowId, fromStepId, recordId, toStepId, note } = req.body;
|
||||||
const userId = (req as any).user?.userId || "system";
|
const userId = (req as any).user?.userId || "system";
|
||||||
|
|
||||||
if (!flowId || !recordId || !toStepId) {
|
if (!flowId || !fromStepId || !recordId || !toStepId) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: "flowId, recordId, and toStepId are required",
|
message: "flowId, fromStepId, recordId, and toStepId are required",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.flowDataMoveService.moveDataToStep(
|
await this.flowDataMoveService.moveDataToStep(
|
||||||
flowId,
|
flowId,
|
||||||
recordId,
|
fromStepId,
|
||||||
toStepId,
|
toStepId,
|
||||||
|
recordId,
|
||||||
userId,
|
userId,
|
||||||
note
|
note ? { note } : undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
|
|
|
||||||
|
|
@ -132,14 +132,23 @@ export class FlowConditionParser {
|
||||||
/**
|
/**
|
||||||
* SQL 인젝션 방지를 위한 컬럼명 검증
|
* SQL 인젝션 방지를 위한 컬럼명 검증
|
||||||
*/
|
*/
|
||||||
private static sanitizeColumnName(columnName: string): string {
|
static sanitizeColumnName(columnName: string): string {
|
||||||
// 알파벳, 숫자, 언더스코어, 점(.)만 허용 (테이블명.컬럼명 형태 지원)
|
|
||||||
if (!/^[a-zA-Z0-9_.]+$/.test(columnName)) {
|
if (!/^[a-zA-Z0-9_.]+$/.test(columnName)) {
|
||||||
throw new Error(`Invalid column name: ${columnName}`);
|
throw new Error(`Invalid column name: ${columnName}`);
|
||||||
}
|
}
|
||||||
return columnName;
|
return columnName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SQL 인젝션 방지를 위한 테이블명 검증
|
||||||
|
*/
|
||||||
|
static sanitizeTableName(tableName: string): string {
|
||||||
|
if (!/^[a-zA-Z0-9_.]+$/.test(tableName)) {
|
||||||
|
throw new Error(`Invalid table name: ${tableName}`);
|
||||||
|
}
|
||||||
|
return tableName;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 조건 검증
|
* 조건 검증
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import {
|
||||||
buildInsertQuery,
|
buildInsertQuery,
|
||||||
buildSelectQuery,
|
buildSelectQuery,
|
||||||
} from "./dbQueryBuilder";
|
} from "./dbQueryBuilder";
|
||||||
|
import { FlowConditionParser } from "./flowConditionParser";
|
||||||
|
|
||||||
export class FlowDataMoveService {
|
export class FlowDataMoveService {
|
||||||
private flowDefinitionService: FlowDefinitionService;
|
private flowDefinitionService: FlowDefinitionService;
|
||||||
|
|
@ -236,18 +237,19 @@ export class FlowDataMoveService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusColumn = toStep.statusColumn;
|
const statusColumn = FlowConditionParser.sanitizeColumnName(toStep.statusColumn);
|
||||||
const tableName = fromStep.tableName;
|
const tableName = FlowConditionParser.sanitizeTableName(fromStep.tableName);
|
||||||
|
|
||||||
// 추가 필드 업데이트 준비
|
// 추가 필드 업데이트 준비
|
||||||
const updates: string[] = [`${statusColumn} = $2`, `updated_at = NOW()`];
|
const updates: string[] = [`${statusColumn} = $2`, `updated_at = NOW()`];
|
||||||
const values: any[] = [dataId, toStep.statusValue];
|
const values: any[] = [dataId, toStep.statusValue];
|
||||||
let paramIndex = 3;
|
let paramIndex = 3;
|
||||||
|
|
||||||
// 추가 데이터가 있으면 함께 업데이트
|
// 추가 데이터가 있으면 함께 업데이트 (키 검증 포함)
|
||||||
if (additionalData) {
|
if (additionalData) {
|
||||||
for (const [key, value] of Object.entries(additionalData)) {
|
for (const [key, value] of Object.entries(additionalData)) {
|
||||||
updates.push(`${key} = $${paramIndex}`);
|
const safeKey = FlowConditionParser.sanitizeColumnName(key);
|
||||||
|
updates.push(`${safeKey} = $${paramIndex}`);
|
||||||
values.push(value);
|
values.push(value);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
}
|
}
|
||||||
|
|
@ -276,33 +278,38 @@ export class FlowDataMoveService {
|
||||||
dataId: any,
|
dataId: any,
|
||||||
additionalData?: Record<string, any>
|
additionalData?: Record<string, any>
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
const sourceTable = fromStep.tableName;
|
const sourceTable = FlowConditionParser.sanitizeTableName(fromStep.tableName);
|
||||||
const targetTable = toStep.targetTable || toStep.tableName;
|
const targetTable = FlowConditionParser.sanitizeTableName(toStep.targetTable || toStep.tableName);
|
||||||
const fieldMappings = toStep.fieldMappings || {};
|
const fieldMappings = toStep.fieldMappings || {};
|
||||||
|
|
||||||
// 1. 소스 데이터 조회
|
// 1. 소스 데이터 조회
|
||||||
const selectQuery = `SELECT * FROM ${sourceTable} WHERE id = $1`;
|
const selectQuery = `SELECT * FROM ${sourceTable} WHERE id = $1`;
|
||||||
const sourceResult = await client.query(selectQuery, [dataId]);
|
const sourceResult = await client.query(selectQuery, [dataId]);
|
||||||
|
|
||||||
if (sourceResult.length === 0) {
|
if (sourceResult.rows.length === 0) {
|
||||||
throw new Error(`소스 데이터를 찾을 수 없습니다: ${dataId}`);
|
throw new Error(`소스 데이터를 찾을 수 없습니다: ${dataId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceData = sourceResult[0];
|
const sourceData = sourceResult.rows[0];
|
||||||
|
|
||||||
// 2. 필드 매핑 적용
|
// 2. 필드 매핑 적용
|
||||||
const mappedData: Record<string, any> = {};
|
const mappedData: Record<string, any> = {};
|
||||||
|
|
||||||
// 매핑 정의가 있으면 적용
|
// 매핑 정의가 있으면 적용 (컬럼명 검증)
|
||||||
for (const [sourceField, targetField] of Object.entries(fieldMappings)) {
|
for (const [sourceField, targetField] of Object.entries(fieldMappings)) {
|
||||||
|
FlowConditionParser.sanitizeColumnName(sourceField);
|
||||||
|
FlowConditionParser.sanitizeColumnName(targetField as string);
|
||||||
if (sourceData[sourceField] !== undefined) {
|
if (sourceData[sourceField] !== undefined) {
|
||||||
mappedData[targetField as string] = sourceData[sourceField];
|
mappedData[targetField as string] = sourceData[sourceField];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 추가 데이터 병합
|
// 추가 데이터 병합 (키 검증)
|
||||||
if (additionalData) {
|
if (additionalData) {
|
||||||
Object.assign(mappedData, additionalData);
|
for (const [key, value] of Object.entries(additionalData)) {
|
||||||
|
const safeKey = FlowConditionParser.sanitizeColumnName(key);
|
||||||
|
mappedData[safeKey] = value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 타겟 테이블에 데이터 삽입
|
// 3. 타겟 테이블에 데이터 삽입
|
||||||
|
|
@ -321,7 +328,7 @@ export class FlowDataMoveService {
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const insertResult = await client.query(insertQuery, values);
|
const insertResult = await client.query(insertQuery, values);
|
||||||
return insertResult[0].id;
|
return insertResult.rows[0].id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -349,12 +356,12 @@ export class FlowDataMoveService {
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const stepDataMap: Record<string, string> =
|
const stepDataMap: Record<string, string> =
|
||||||
mappingResult.length > 0 ? mappingResult[0].step_data_map : {};
|
mappingResult.rows.length > 0 ? mappingResult.rows[0].step_data_map : {};
|
||||||
|
|
||||||
// 새 단계 데이터 추가
|
// 새 단계 데이터 추가
|
||||||
stepDataMap[String(currentStepId)] = String(targetDataId);
|
stepDataMap[String(currentStepId)] = String(targetDataId);
|
||||||
|
|
||||||
if (mappingResult.length > 0) {
|
if (mappingResult.rows.length > 0) {
|
||||||
// 기존 매핑 업데이트
|
// 기존 매핑 업데이트
|
||||||
const updateQuery = `
|
const updateQuery = `
|
||||||
UPDATE flow_data_mapping
|
UPDATE flow_data_mapping
|
||||||
|
|
@ -366,7 +373,7 @@ export class FlowDataMoveService {
|
||||||
await client.query(updateQuery, [
|
await client.query(updateQuery, [
|
||||||
currentStepId,
|
currentStepId,
|
||||||
JSON.stringify(stepDataMap),
|
JSON.stringify(stepDataMap),
|
||||||
mappingResult[0].id,
|
mappingResult.rows[0].id,
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
// 새 매핑 생성
|
// 새 매핑 생성
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,8 @@ export class FlowDefinitionService {
|
||||||
userId: string,
|
userId: string,
|
||||||
userCompanyCode?: string
|
userCompanyCode?: string
|
||||||
): Promise<FlowDefinition> {
|
): Promise<FlowDefinition> {
|
||||||
const companyCode = request.companyCode || userCompanyCode || "*";
|
// 클라이언트 입력(request.companyCode) 무시 - 인증된 사용자의 회사 코드만 사용
|
||||||
|
const companyCode = userCompanyCode || "*";
|
||||||
|
|
||||||
console.log("🔥 flowDefinitionService.create called with:", {
|
console.log("🔥 flowDefinitionService.create called with:", {
|
||||||
name: request.name,
|
name: request.name,
|
||||||
|
|
@ -118,10 +119,21 @@ export class FlowDefinitionService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 플로우 정의 단일 조회
|
* 플로우 정의 단일 조회
|
||||||
|
* companyCode가 전달되면 해당 회사 소유 플로우만 반환
|
||||||
*/
|
*/
|
||||||
async findById(id: number): Promise<FlowDefinition | null> {
|
async findById(id: number, companyCode?: string): Promise<FlowDefinition | null> {
|
||||||
const query = "SELECT * FROM flow_definition WHERE id = $1";
|
let query: string;
|
||||||
const result = await db.query(query, [id]);
|
let params: any[];
|
||||||
|
|
||||||
|
if (companyCode && companyCode !== "*") {
|
||||||
|
query = "SELECT * FROM flow_definition WHERE id = $1 AND company_code = $2";
|
||||||
|
params = [id, companyCode];
|
||||||
|
} else {
|
||||||
|
query = "SELECT * FROM flow_definition WHERE id = $1";
|
||||||
|
params = [id];
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db.query(query, params);
|
||||||
|
|
||||||
if (result.length === 0) {
|
if (result.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -132,10 +144,12 @@ export class FlowDefinitionService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 플로우 정의 수정
|
* 플로우 정의 수정
|
||||||
|
* companyCode가 전달되면 해당 회사 소유 플로우만 수정 가능
|
||||||
*/
|
*/
|
||||||
async update(
|
async update(
|
||||||
id: number,
|
id: number,
|
||||||
request: UpdateFlowDefinitionRequest
|
request: UpdateFlowDefinitionRequest,
|
||||||
|
companyCode?: string
|
||||||
): Promise<FlowDefinition | null> {
|
): Promise<FlowDefinition | null> {
|
||||||
const fields: string[] = [];
|
const fields: string[] = [];
|
||||||
const params: any[] = [];
|
const params: any[] = [];
|
||||||
|
|
@ -160,18 +174,27 @@ export class FlowDefinitionService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fields.length === 0) {
|
if (fields.length === 0) {
|
||||||
return this.findById(id);
|
return this.findById(id, companyCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
fields.push(`updated_at = NOW()`);
|
fields.push(`updated_at = NOW()`);
|
||||||
|
|
||||||
|
let whereClause = `WHERE id = $${paramIndex}`;
|
||||||
|
params.push(id);
|
||||||
|
paramIndex++;
|
||||||
|
|
||||||
|
if (companyCode && companyCode !== "*") {
|
||||||
|
whereClause += ` AND company_code = $${paramIndex}`;
|
||||||
|
params.push(companyCode);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
const query = `
|
const query = `
|
||||||
UPDATE flow_definition
|
UPDATE flow_definition
|
||||||
SET ${fields.join(", ")}
|
SET ${fields.join(", ")}
|
||||||
WHERE id = $${paramIndex}
|
${whereClause}
|
||||||
RETURNING *
|
RETURNING *
|
||||||
`;
|
`;
|
||||||
params.push(id);
|
|
||||||
|
|
||||||
const result = await db.query(query, params);
|
const result = await db.query(query, params);
|
||||||
|
|
||||||
|
|
@ -184,10 +207,21 @@ export class FlowDefinitionService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 플로우 정의 삭제
|
* 플로우 정의 삭제
|
||||||
|
* companyCode가 전달되면 해당 회사 소유 플로우만 삭제 가능
|
||||||
*/
|
*/
|
||||||
async delete(id: number): Promise<boolean> {
|
async delete(id: number, companyCode?: string): Promise<boolean> {
|
||||||
const query = "DELETE FROM flow_definition WHERE id = $1 RETURNING id";
|
let query: string;
|
||||||
const result = await db.query(query, [id]);
|
let params: any[];
|
||||||
|
|
||||||
|
if (companyCode && companyCode !== "*") {
|
||||||
|
query = "DELETE FROM flow_definition WHERE id = $1 AND company_code = $2 RETURNING id";
|
||||||
|
params = [id, companyCode];
|
||||||
|
} else {
|
||||||
|
query = "DELETE FROM flow_definition WHERE id = $1 RETURNING id";
|
||||||
|
params = [id];
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db.query(query, params);
|
||||||
return result.length > 0;
|
return result.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { FlowStepService } from "./flowStepService";
|
||||||
import { FlowConditionParser } from "./flowConditionParser";
|
import { FlowConditionParser } from "./flowConditionParser";
|
||||||
import { executeExternalQuery } from "./externalDbHelper";
|
import { executeExternalQuery } from "./externalDbHelper";
|
||||||
import { getPlaceholder, buildPaginationClause } from "./dbQueryBuilder";
|
import { getPlaceholder, buildPaginationClause } from "./dbQueryBuilder";
|
||||||
|
import { FlowConditionParser } from "./flowConditionParser";
|
||||||
|
|
||||||
export class FlowExecutionService {
|
export class FlowExecutionService {
|
||||||
private flowDefinitionService: FlowDefinitionService;
|
private flowDefinitionService: FlowDefinitionService;
|
||||||
|
|
@ -42,7 +43,7 @@ export class FlowExecutionService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 테이블명 결정: 단계에 지정된 테이블이 있으면 사용, 없으면 플로우의 기본 테이블 사용
|
// 3. 테이블명 결정: 단계에 지정된 테이블이 있으면 사용, 없으면 플로우의 기본 테이블 사용
|
||||||
const tableName = step.tableName || flowDef.tableName;
|
const tableName = FlowConditionParser.sanitizeTableName(step.tableName || flowDef.tableName);
|
||||||
|
|
||||||
// 4. 조건 JSON을 SQL WHERE절로 변환
|
// 4. 조건 JSON을 SQL WHERE절로 변환
|
||||||
const { where, params } = FlowConditionParser.toSqlWhere(
|
const { where, params } = FlowConditionParser.toSqlWhere(
|
||||||
|
|
@ -96,7 +97,7 @@ export class FlowExecutionService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 테이블명 결정: 단계에 지정된 테이블이 있으면 사용, 없으면 플로우의 기본 테이블 사용
|
// 3. 테이블명 결정: 단계에 지정된 테이블이 있으면 사용, 없으면 플로우의 기본 테이블 사용
|
||||||
const tableName = step.tableName || flowDef.tableName;
|
const tableName = FlowConditionParser.sanitizeTableName(step.tableName || flowDef.tableName);
|
||||||
|
|
||||||
// 4. 조건 JSON을 SQL WHERE절로 변환
|
// 4. 조건 JSON을 SQL WHERE절로 변환
|
||||||
const { where, params } = FlowConditionParser.toSqlWhere(
|
const { where, params } = FlowConditionParser.toSqlWhere(
|
||||||
|
|
@ -267,11 +268,12 @@ export class FlowExecutionService {
|
||||||
throw new Error(`Flow step not found: ${stepId}`);
|
throw new Error(`Flow step not found: ${stepId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 테이블명 결정
|
// 3. 테이블명 결정 (SQL 인젝션 방지)
|
||||||
const tableName = step.tableName || flowDef.tableName;
|
const rawTableName = step.tableName || flowDef.tableName;
|
||||||
if (!tableName) {
|
if (!rawTableName) {
|
||||||
throw new Error("Table name not found");
|
throw new Error("Table name not found");
|
||||||
}
|
}
|
||||||
|
const tableName = FlowConditionParser.sanitizeTableName(rawTableName);
|
||||||
|
|
||||||
// 4. Primary Key 컬럼 결정 (기본값: id)
|
// 4. Primary Key 컬럼 결정 (기본값: id)
|
||||||
const primaryKeyColumn = flowDef.primaryKey || "id";
|
const primaryKeyColumn = flowDef.primaryKey || "id";
|
||||||
|
|
@ -280,8 +282,10 @@ export class FlowExecutionService {
|
||||||
`🔍 [updateStepData] Updating table: ${tableName}, PK: ${primaryKeyColumn}=${recordId}`
|
`🔍 [updateStepData] Updating table: ${tableName}, PK: ${primaryKeyColumn}=${recordId}`
|
||||||
);
|
);
|
||||||
|
|
||||||
// 5. SET 절 생성
|
// 5. SET 절 생성 (컬럼명 SQL 인젝션 방지)
|
||||||
const updateColumns = Object.keys(updateData);
|
const updateColumns = Object.keys(updateData).map((col) =>
|
||||||
|
FlowConditionParser.sanitizeColumnName(col)
|
||||||
|
);
|
||||||
if (updateColumns.length === 0) {
|
if (updateColumns.length === 0) {
|
||||||
throw new Error("No columns to update");
|
throw new Error("No columns to update");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -260,6 +260,7 @@ export interface FlowStepDataList {
|
||||||
// 데이터 이동 요청
|
// 데이터 이동 요청
|
||||||
export interface MoveDataRequest {
|
export interface MoveDataRequest {
|
||||||
flowId: number;
|
flowId: number;
|
||||||
|
fromStepId: number;
|
||||||
recordId: string;
|
recordId: string;
|
||||||
toStepId: number;
|
toStepId: number;
|
||||||
note?: string;
|
note?: string;
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { Badge } from "@/components/ui/badge";
|
||||||
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
|
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Loader2, AlertCircle, ArrowRight } from "lucide-react";
|
import { Loader2, AlertCircle, ArrowRight } from "lucide-react";
|
||||||
import { getStepDataList, moveDataToNextStep } from "@/lib/api/flow";
|
import { getStepDataList, moveBatchData, getFlowConnections } from "@/lib/api/flow";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
interface FlowDataListModalProps {
|
interface FlowDataListModalProps {
|
||||||
|
|
@ -102,15 +102,28 @@ export function FlowDataListModal({
|
||||||
try {
|
try {
|
||||||
setMovingData(true);
|
setMovingData(true);
|
||||||
|
|
||||||
// 선택된 행의 ID 추출 (가정: 각 행에 'id' 필드가 있음)
|
// 다음 스텝 결정 (연결 정보에서 조회)
|
||||||
const selectedDataIds = Array.from(selectedRows).map((index) => data[index].id);
|
const connResponse = await getFlowConnections(flowId);
|
||||||
|
if (!connResponse.success || !connResponse.data) {
|
||||||
// 데이터 이동 API 호출
|
throw new Error("플로우 연결 정보를 가져올 수 없습니다");
|
||||||
for (const dataId of selectedDataIds) {
|
|
||||||
const response = await moveDataToNextStep(flowId, stepId, dataId);
|
|
||||||
if (!response.success) {
|
|
||||||
throw new Error(`데이터 이동 실패: ${response.message}`);
|
|
||||||
}
|
}
|
||||||
|
const nextConn = connResponse.data.find((c: any) => c.fromStepId === stepId);
|
||||||
|
if (!nextConn) {
|
||||||
|
throw new Error("다음 단계가 연결되어 있지 않습니다");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 선택된 행의 ID 추출
|
||||||
|
const selectedDataIds = Array.from(selectedRows).map((index) => String(data[index].id));
|
||||||
|
|
||||||
|
// 배치 이동 API 호출
|
||||||
|
const response = await moveBatchData({
|
||||||
|
flowId,
|
||||||
|
fromStepId: stepId,
|
||||||
|
toStepId: nextConn.toStepId,
|
||||||
|
dataIds: selectedDataIds,
|
||||||
|
});
|
||||||
|
if (!response.success) {
|
||||||
|
throw new Error(`데이터 이동 실패: ${response.error || "알 수 없는 오류"}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.success(`${selectedRows.size}건의 데이터를 다음 단계로 이동했습니다`);
|
toast.success(`${selectedRows.size}건의 데이터를 다음 단계로 이동했습니다`);
|
||||||
|
|
|
||||||
|
|
@ -451,13 +451,15 @@ export async function moveData(data: MoveDataRequest): Promise<ApiResponse<{ suc
|
||||||
*/
|
*/
|
||||||
export async function moveDataToNextStep(
|
export async function moveDataToNextStep(
|
||||||
flowId: number,
|
flowId: number,
|
||||||
currentStepId: number,
|
fromStepId: number,
|
||||||
dataId: number,
|
toStepId: number,
|
||||||
|
recordId: string | number,
|
||||||
): Promise<ApiResponse<{ success: boolean }>> {
|
): Promise<ApiResponse<{ success: boolean }>> {
|
||||||
return moveData({
|
return moveData({
|
||||||
flowId,
|
flowId,
|
||||||
currentStepId,
|
fromStepId,
|
||||||
dataId,
|
recordId: String(recordId),
|
||||||
|
toStepId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -235,6 +235,7 @@ export interface FlowStepDataList {
|
||||||
|
|
||||||
export interface MoveDataRequest {
|
export interface MoveDataRequest {
|
||||||
flowId: number;
|
flowId: number;
|
||||||
|
fromStepId: number;
|
||||||
recordId: string;
|
recordId: string;
|
||||||
toStepId: number;
|
toStepId: number;
|
||||||
note?: string;
|
note?: string;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue