플로우 구현

This commit is contained in:
kjs
2025-10-20 10:55:33 +09:00
parent 6603ff81fe
commit f9c171c513
37 changed files with 6881 additions and 238 deletions

View File

@@ -0,0 +1,203 @@
/**
* 플로우 조건 파서
* JSON 조건을 SQL WHERE 절로 변환
*/
import {
FlowCondition,
FlowConditionGroup,
SqlWhereResult,
} from "../types/flow";
export class FlowConditionParser {
/**
* 조건 JSON을 SQL WHERE 절로 변환
*/
static toSqlWhere(
conditionGroup: FlowConditionGroup | null | undefined
): SqlWhereResult {
if (
!conditionGroup ||
!conditionGroup.conditions ||
conditionGroup.conditions.length === 0
) {
return { where: "1=1", params: [] };
}
const conditions: string[] = [];
const params: any[] = [];
let paramIndex = 1;
for (const condition of conditionGroup.conditions) {
const column = this.sanitizeColumnName(condition.column);
switch (condition.operator) {
case "equals":
conditions.push(`${column} = $${paramIndex}`);
params.push(condition.value);
paramIndex++;
break;
case "not_equals":
conditions.push(`${column} != $${paramIndex}`);
params.push(condition.value);
paramIndex++;
break;
case "in":
if (Array.isArray(condition.value) && condition.value.length > 0) {
const placeholders = condition.value
.map(() => `$${paramIndex++}`)
.join(", ");
conditions.push(`${column} IN (${placeholders})`);
params.push(...condition.value);
}
break;
case "not_in":
if (Array.isArray(condition.value) && condition.value.length > 0) {
const placeholders = condition.value
.map(() => `$${paramIndex++}`)
.join(", ");
conditions.push(`${column} NOT IN (${placeholders})`);
params.push(...condition.value);
}
break;
case "greater_than":
conditions.push(`${column} > $${paramIndex}`);
params.push(condition.value);
paramIndex++;
break;
case "less_than":
conditions.push(`${column} < $${paramIndex}`);
params.push(condition.value);
paramIndex++;
break;
case "greater_than_or_equal":
conditions.push(`${column} >= $${paramIndex}`);
params.push(condition.value);
paramIndex++;
break;
case "less_than_or_equal":
conditions.push(`${column} <= $${paramIndex}`);
params.push(condition.value);
paramIndex++;
break;
case "is_null":
conditions.push(`${column} IS NULL`);
break;
case "is_not_null":
conditions.push(`${column} IS NOT NULL`);
break;
case "like":
conditions.push(`${column} LIKE $${paramIndex}`);
params.push(`%${condition.value}%`);
paramIndex++;
break;
case "not_like":
conditions.push(`${column} NOT LIKE $${paramIndex}`);
params.push(`%${condition.value}%`);
paramIndex++;
break;
default:
throw new Error(`Unsupported operator: ${condition.operator}`);
}
}
if (conditions.length === 0) {
return { where: "1=1", params: [] };
}
const joinOperator = conditionGroup.type === "OR" ? " OR " : " AND ";
const where = conditions.join(joinOperator);
return { where, params };
}
/**
* SQL 인젝션 방지를 위한 컬럼명 검증
*/
private static sanitizeColumnName(columnName: string): string {
// 알파벳, 숫자, 언더스코어, 점(.)만 허용 (테이블명.컬럼명 형태 지원)
if (!/^[a-zA-Z0-9_.]+$/.test(columnName)) {
throw new Error(`Invalid column name: ${columnName}`);
}
return columnName;
}
/**
* 조건 검증
*/
static validateConditionGroup(conditionGroup: FlowConditionGroup): void {
if (!conditionGroup) {
throw new Error("Condition group is required");
}
if (!["AND", "OR"].includes(conditionGroup.type)) {
throw new Error("Condition group type must be AND or OR");
}
if (!Array.isArray(conditionGroup.conditions)) {
throw new Error("Conditions must be an array");
}
for (const condition of conditionGroup.conditions) {
this.validateCondition(condition);
}
}
/**
* 개별 조건 검증
*/
private static validateCondition(condition: FlowCondition): void {
if (!condition.column) {
throw new Error("Column name is required");
}
const validOperators = [
"equals",
"not_equals",
"in",
"not_in",
"greater_than",
"less_than",
"greater_than_or_equal",
"less_than_or_equal",
"is_null",
"is_not_null",
"like",
"not_like",
];
if (!validOperators.includes(condition.operator)) {
throw new Error(`Invalid operator: ${condition.operator}`);
}
// is_null, is_not_null은 value가 필요 없음
if (!["is_null", "is_not_null"].includes(condition.operator)) {
if (condition.value === undefined || condition.value === null) {
throw new Error(
`Value is required for operator: ${condition.operator}`
);
}
}
// in, not_in은 배열이어야 함
if (["in", "not_in"].includes(condition.operator)) {
if (!Array.isArray(condition.value) || condition.value.length === 0) {
throw new Error(
`Operator ${condition.operator} requires a non-empty array value`
);
}
}
}
}

View File

@@ -0,0 +1,166 @@
/**
* 플로우 연결 서비스
*/
import db from "../database/db";
import { FlowStepConnection, CreateFlowConnectionRequest } from "../types/flow";
export class FlowConnectionService {
/**
* 플로우 단계 연결 생성
*/
async create(
request: CreateFlowConnectionRequest
): Promise<FlowStepConnection> {
// 순환 참조 체크
if (
await this.wouldCreateCycle(
request.flowDefinitionId,
request.fromStepId,
request.toStepId
)
) {
throw new Error(
"Creating this connection would create a cycle in the flow"
);
}
const query = `
INSERT INTO flow_step_connection (
flow_definition_id, from_step_id, to_step_id, label
)
VALUES ($1, $2, $3, $4)
RETURNING *
`;
const result = await db.query(query, [
request.flowDefinitionId,
request.fromStepId,
request.toStepId,
request.label || null,
]);
return this.mapToFlowConnection(result[0]);
}
/**
* 특정 플로우의 모든 연결 조회
*/
async findByFlowId(flowDefinitionId: number): Promise<FlowStepConnection[]> {
const query = `
SELECT * FROM flow_step_connection
WHERE flow_definition_id = $1
ORDER BY id ASC
`;
const result = await db.query(query, [flowDefinitionId]);
return result.map(this.mapToFlowConnection);
}
/**
* 플로우 연결 단일 조회
*/
async findById(id: number): Promise<FlowStepConnection | null> {
const query = "SELECT * FROM flow_step_connection WHERE id = $1";
const result = await db.query(query, [id]);
if (result.length === 0) {
return null;
}
return this.mapToFlowConnection(result[0]);
}
/**
* 플로우 연결 삭제
*/
async delete(id: number): Promise<boolean> {
const query = "DELETE FROM flow_step_connection WHERE id = $1 RETURNING id";
const result = await db.query(query, [id]);
return result.length > 0;
}
/**
* 특정 단계에서 나가는 연결 조회
*/
async findOutgoingConnections(stepId: number): Promise<FlowStepConnection[]> {
const query = `
SELECT * FROM flow_step_connection
WHERE from_step_id = $1
ORDER BY id ASC
`;
const result = await db.query(query, [stepId]);
return result.map(this.mapToFlowConnection);
}
/**
* 특정 단계로 들어오는 연결 조회
*/
async findIncomingConnections(stepId: number): Promise<FlowStepConnection[]> {
const query = `
SELECT * FROM flow_step_connection
WHERE to_step_id = $1
ORDER BY id ASC
`;
const result = await db.query(query, [stepId]);
return result.map(this.mapToFlowConnection);
}
/**
* 순환 참조 체크 (DFS)
*/
private async wouldCreateCycle(
flowDefinitionId: number,
fromStepId: number,
toStepId: number
): Promise<boolean> {
// toStepId에서 출발해서 fromStepId에 도달할 수 있는지 확인
const visited = new Set<number>();
const stack = [toStepId];
while (stack.length > 0) {
const current = stack.pop()!;
if (current === fromStepId) {
return true; // 순환 발견
}
if (visited.has(current)) {
continue;
}
visited.add(current);
// 현재 노드에서 나가는 모든 연결 조회
const query = `
SELECT to_step_id
FROM flow_step_connection
WHERE flow_definition_id = $1 AND from_step_id = $2
`;
const result = await db.query(query, [flowDefinitionId, current]);
for (const row of result) {
stack.push(row.to_step_id);
}
}
return false; // 순환 없음
}
/**
* DB 행을 FlowStepConnection 객체로 변환
*/
private mapToFlowConnection(row: any): FlowStepConnection {
return {
id: row.id,
flowDefinitionId: row.flow_definition_id,
fromStepId: row.from_step_id,
toStepId: row.to_step_id,
label: row.label,
createdAt: row.created_at,
};
}
}

View File

@@ -0,0 +1,181 @@
/**
* 플로우 데이터 이동 서비스
* 데이터의 플로우 단계 이동 및 오딧 로그 관리
*/
import db from "../database/db";
import { FlowAuditLog } from "../types/flow";
import { FlowDefinitionService } from "./flowDefinitionService";
export class FlowDataMoveService {
private flowDefinitionService: FlowDefinitionService;
constructor() {
this.flowDefinitionService = new FlowDefinitionService();
}
/**
* 데이터를 다음 플로우 단계로 이동
*/
async moveDataToStep(
flowId: number,
recordId: string,
toStepId: number,
userId: string,
note?: string
): Promise<void> {
await db.transaction(async (client) => {
// 1. 플로우 정의 조회
const flowDef = await this.flowDefinitionService.findById(flowId);
if (!flowDef) {
throw new Error(`Flow definition not found: ${flowId}`);
}
// 2. 현재 상태 조회
const currentStatusQuery = `
SELECT current_step_id, table_name
FROM flow_data_status
WHERE flow_definition_id = $1 AND record_id = $2
`;
const currentStatusResult = await client.query(currentStatusQuery, [
flowId,
recordId,
]);
const currentStatus =
currentStatusResult.rows.length > 0
? {
currentStepId: currentStatusResult.rows[0].current_step_id,
tableName: currentStatusResult.rows[0].table_name,
}
: null;
const fromStepId = currentStatus?.currentStepId || null;
// 3. flow_data_status 업데이트 또는 삽입
if (currentStatus) {
await client.query(
`
UPDATE flow_data_status
SET current_step_id = $1, updated_by = $2, updated_at = NOW()
WHERE flow_definition_id = $3 AND record_id = $4
`,
[toStepId, userId, flowId, recordId]
);
} else {
await client.query(
`
INSERT INTO flow_data_status
(flow_definition_id, table_name, record_id, current_step_id, updated_by)
VALUES ($1, $2, $3, $4, $5)
`,
[flowId, flowDef.tableName, recordId, toStepId, userId]
);
}
// 4. 오딧 로그 기록
await client.query(
`
INSERT INTO flow_audit_log
(flow_definition_id, table_name, record_id, from_step_id, to_step_id, changed_by, note)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`,
[
flowId,
flowDef.tableName,
recordId,
fromStepId,
toStepId,
userId,
note || null,
]
);
});
}
/**
* 여러 데이터를 동시에 다음 단계로 이동
*/
async moveBatchData(
flowId: number,
recordIds: string[],
toStepId: number,
userId: string,
note?: string
): Promise<void> {
for (const recordId of recordIds) {
await this.moveDataToStep(flowId, recordId, toStepId, userId, note);
}
}
/**
* 데이터의 플로우 이력 조회
*/
async getAuditLogs(
flowId: number,
recordId: string
): Promise<FlowAuditLog[]> {
const query = `
SELECT
fal.*,
fs_from.step_name as from_step_name,
fs_to.step_name as to_step_name
FROM flow_audit_log fal
LEFT JOIN flow_step fs_from ON fal.from_step_id = fs_from.id
LEFT JOIN flow_step fs_to ON fal.to_step_id = fs_to.id
WHERE fal.flow_definition_id = $1 AND fal.record_id = $2
ORDER BY fal.changed_at DESC
`;
const result = await db.query(query, [flowId, recordId]);
return result.map((row) => ({
id: row.id,
flowDefinitionId: row.flow_definition_id,
tableName: row.table_name,
recordId: row.record_id,
fromStepId: row.from_step_id,
toStepId: row.to_step_id,
changedBy: row.changed_by,
changedAt: row.changed_at,
note: row.note,
fromStepName: row.from_step_name,
toStepName: row.to_step_name,
}));
}
/**
* 특정 플로우의 모든 이력 조회
*/
async getFlowAuditLogs(
flowId: number,
limit: number = 100
): Promise<FlowAuditLog[]> {
const query = `
SELECT
fal.*,
fs_from.step_name as from_step_name,
fs_to.step_name as to_step_name
FROM flow_audit_log fal
LEFT JOIN flow_step fs_from ON fal.from_step_id = fs_from.id
LEFT JOIN flow_step fs_to ON fal.to_step_id = fs_to.id
WHERE fal.flow_definition_id = $1
ORDER BY fal.changed_at DESC
LIMIT $2
`;
const result = await db.query(query, [flowId, limit]);
return result.map((row) => ({
id: row.id,
flowDefinitionId: row.flow_definition_id,
tableName: row.table_name,
recordId: row.record_id,
fromStepId: row.from_step_id,
toStepId: row.to_step_id,
changedBy: row.changed_by,
changedAt: row.changed_at,
note: row.note,
fromStepName: row.from_step_name,
toStepName: row.to_step_name,
}));
}
}

View File

@@ -0,0 +1,171 @@
/**
* 플로우 정의 서비스
*/
import db from "../database/db";
import {
FlowDefinition,
CreateFlowDefinitionRequest,
UpdateFlowDefinitionRequest,
} from "../types/flow";
export class FlowDefinitionService {
/**
* 플로우 정의 생성
*/
async create(
request: CreateFlowDefinitionRequest,
userId: string
): Promise<FlowDefinition> {
const query = `
INSERT INTO flow_definition (name, description, table_name, created_by)
VALUES ($1, $2, $3, $4)
RETURNING *
`;
const result = await db.query(query, [
request.name,
request.description || null,
request.tableName,
userId,
]);
return this.mapToFlowDefinition(result[0]);
}
/**
* 플로우 정의 목록 조회
*/
async findAll(
tableName?: string,
isActive?: boolean
): Promise<FlowDefinition[]> {
let query = "SELECT * FROM flow_definition WHERE 1=1";
const params: any[] = [];
let paramIndex = 1;
if (tableName) {
query += ` AND table_name = $${paramIndex}`;
params.push(tableName);
paramIndex++;
}
if (isActive !== undefined) {
query += ` AND is_active = $${paramIndex}`;
params.push(isActive);
paramIndex++;
}
query += " ORDER BY created_at DESC";
const result = await db.query(query, params);
return result.map(this.mapToFlowDefinition);
}
/**
* 플로우 정의 단일 조회
*/
async findById(id: number): Promise<FlowDefinition | null> {
const query = "SELECT * FROM flow_definition WHERE id = $1";
const result = await db.query(query, [id]);
if (result.length === 0) {
return null;
}
return this.mapToFlowDefinition(result[0]);
}
/**
* 플로우 정의 수정
*/
async update(
id: number,
request: UpdateFlowDefinitionRequest
): Promise<FlowDefinition | null> {
const fields: string[] = [];
const params: any[] = [];
let paramIndex = 1;
if (request.name !== undefined) {
fields.push(`name = $${paramIndex}`);
params.push(request.name);
paramIndex++;
}
if (request.description !== undefined) {
fields.push(`description = $${paramIndex}`);
params.push(request.description);
paramIndex++;
}
if (request.isActive !== undefined) {
fields.push(`is_active = $${paramIndex}`);
params.push(request.isActive);
paramIndex++;
}
if (fields.length === 0) {
return this.findById(id);
}
fields.push(`updated_at = NOW()`);
const query = `
UPDATE flow_definition
SET ${fields.join(", ")}
WHERE id = $${paramIndex}
RETURNING *
`;
params.push(id);
const result = await db.query(query, params);
if (result.length === 0) {
return null;
}
return this.mapToFlowDefinition(result[0]);
}
/**
* 플로우 정의 삭제
*/
async delete(id: number): Promise<boolean> {
const query = "DELETE FROM flow_definition WHERE id = $1 RETURNING id";
const result = await db.query(query, [id]);
return result.length > 0;
}
/**
* 테이블 존재 여부 확인
*/
async checkTableExists(tableName: string): Promise<boolean> {
const query = `
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = $1
) as exists
`;
const result = await db.query(query, [tableName]);
return result[0].exists;
}
/**
* DB 행을 FlowDefinition 객체로 변환
*/
private mapToFlowDefinition(row: any): FlowDefinition {
return {
id: row.id,
name: row.name,
description: row.description,
tableName: row.table_name,
isActive: row.is_active,
createdBy: row.created_by,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
}

View File

@@ -0,0 +1,176 @@
/**
* 플로우 실행 서비스
* 단계별 데이터 카운트 및 리스트 조회
*/
import db from "../database/db";
import { FlowStepDataCount, FlowStepDataList } from "../types/flow";
import { FlowDefinitionService } from "./flowDefinitionService";
import { FlowStepService } from "./flowStepService";
import { FlowConditionParser } from "./flowConditionParser";
export class FlowExecutionService {
private flowDefinitionService: FlowDefinitionService;
private flowStepService: FlowStepService;
constructor() {
this.flowDefinitionService = new FlowDefinitionService();
this.flowStepService = new FlowStepService();
}
/**
* 특정 플로우 단계에 해당하는 데이터 카운트
*/
async getStepDataCount(flowId: number, stepId: number): Promise<number> {
// 1. 플로우 정의 조회
const flowDef = await this.flowDefinitionService.findById(flowId);
if (!flowDef) {
throw new Error(`Flow definition not found: ${flowId}`);
}
// 2. 플로우 단계 조회
const step = await this.flowStepService.findById(stepId);
if (!step) {
throw new Error(`Flow step not found: ${stepId}`);
}
if (step.flowDefinitionId !== flowId) {
throw new Error(`Step ${stepId} does not belong to flow ${flowId}`);
}
// 3. 테이블명 결정: 단계에 지정된 테이블이 있으면 사용, 없으면 플로우의 기본 테이블 사용
const tableName = step.tableName || flowDef.tableName;
// 4. 조건 JSON을 SQL WHERE절로 변환
const { where, params } = FlowConditionParser.toSqlWhere(
step.conditionJson
);
// 5. 카운트 쿼리 실행
const query = `SELECT COUNT(*) as count FROM ${tableName} WHERE ${where}`;
const result = await db.query(query, params);
return parseInt(result[0].count);
}
/**
* 특정 플로우 단계에 해당하는 데이터 리스트
*/
async getStepDataList(
flowId: number,
stepId: number,
page: number = 1,
pageSize: number = 20
): Promise<FlowStepDataList> {
// 1. 플로우 정의 조회
const flowDef = await this.flowDefinitionService.findById(flowId);
if (!flowDef) {
throw new Error(`Flow definition not found: ${flowId}`);
}
// 2. 플로우 단계 조회
const step = await this.flowStepService.findById(stepId);
if (!step) {
throw new Error(`Flow step not found: ${stepId}`);
}
if (step.flowDefinitionId !== flowId) {
throw new Error(`Step ${stepId} does not belong to flow ${flowId}`);
}
// 3. 테이블명 결정: 단계에 지정된 테이블이 있으면 사용, 없으면 플로우의 기본 테이블 사용
const tableName = step.tableName || flowDef.tableName;
// 4. 조건 JSON을 SQL WHERE절로 변환
const { where, params } = FlowConditionParser.toSqlWhere(
step.conditionJson
);
const offset = (page - 1) * pageSize;
// 5. 전체 카운트
const countQuery = `SELECT COUNT(*) as count FROM ${tableName} WHERE ${where}`;
const countResult = await db.query(countQuery, params);
const total = parseInt(countResult[0].count);
// 6. 테이블의 Primary Key 컬럼 찾기
let orderByColumn = "";
try {
const pkQuery = `
SELECT a.attname
FROM pg_index i
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
WHERE i.indrelid = $1::regclass
AND i.indisprimary
LIMIT 1
`;
const pkResult = await db.query(pkQuery, [tableName]);
if (pkResult.length > 0) {
orderByColumn = pkResult[0].attname;
}
} catch (err) {
// Primary Key를 찾지 못하면 ORDER BY 없이 진행
console.warn(`Could not find primary key for table ${tableName}:`, err);
}
// 7. 데이터 조회
const orderByClause = orderByColumn ? `ORDER BY ${orderByColumn} DESC` : "";
const dataQuery = `
SELECT * FROM ${tableName}
WHERE ${where}
${orderByClause}
LIMIT $${params.length + 1} OFFSET $${params.length + 2}
`;
const dataResult = await db.query(dataQuery, [...params, pageSize, offset]);
return {
records: dataResult,
total,
page,
pageSize,
};
}
/**
* 플로우의 모든 단계별 데이터 카운트
*/
async getAllStepCounts(flowId: number): Promise<FlowStepDataCount[]> {
const steps = await this.flowStepService.findByFlowId(flowId);
const counts: FlowStepDataCount[] = [];
for (const step of steps) {
const count = await this.getStepDataCount(flowId, step.id);
counts.push({
stepId: step.id,
count,
});
}
return counts;
}
/**
* 특정 레코드의 현재 플로우 상태 조회
*/
async getCurrentStatus(
flowId: number,
recordId: string
): Promise<{ currentStepId: number | null; tableName: string } | null> {
const query = `
SELECT current_step_id, table_name
FROM flow_data_status
WHERE flow_definition_id = $1 AND record_id = $2
`;
const result = await db.query(query, [flowId, recordId]);
if (result.length === 0) {
return null;
}
return {
currentStepId: result[0].current_step_id,
tableName: result[0].table_name,
};
}
}

View File

@@ -0,0 +1,202 @@
/**
* 플로우 단계 서비스
*/
import db from "../database/db";
import {
FlowStep,
CreateFlowStepRequest,
UpdateFlowStepRequest,
FlowConditionGroup,
} from "../types/flow";
import { FlowConditionParser } from "./flowConditionParser";
export class FlowStepService {
/**
* 플로우 단계 생성
*/
async create(request: CreateFlowStepRequest): Promise<FlowStep> {
// 조건 검증
if (request.conditionJson) {
FlowConditionParser.validateConditionGroup(request.conditionJson);
}
const query = `
INSERT INTO flow_step (
flow_definition_id, step_name, step_order, table_name, condition_json,
color, position_x, position_y
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *
`;
const result = await db.query(query, [
request.flowDefinitionId,
request.stepName,
request.stepOrder,
request.tableName || null,
request.conditionJson ? JSON.stringify(request.conditionJson) : null,
request.color || "#3B82F6",
request.positionX || 0,
request.positionY || 0,
]);
return this.mapToFlowStep(result[0]);
}
/**
* 특정 플로우의 모든 단계 조회
*/
async findByFlowId(flowDefinitionId: number): Promise<FlowStep[]> {
const query = `
SELECT * FROM flow_step
WHERE flow_definition_id = $1
ORDER BY step_order ASC
`;
const result = await db.query(query, [flowDefinitionId]);
return result.map(this.mapToFlowStep);
}
/**
* 플로우 단계 단일 조회
*/
async findById(id: number): Promise<FlowStep | null> {
const query = "SELECT * FROM flow_step WHERE id = $1";
const result = await db.query(query, [id]);
if (result.length === 0) {
return null;
}
return this.mapToFlowStep(result[0]);
}
/**
* 플로우 단계 수정
*/
async update(
id: number,
request: UpdateFlowStepRequest
): Promise<FlowStep | null> {
// 조건 검증
if (request.conditionJson) {
FlowConditionParser.validateConditionGroup(request.conditionJson);
}
const fields: string[] = [];
const params: any[] = [];
let paramIndex = 1;
if (request.stepName !== undefined) {
fields.push(`step_name = $${paramIndex}`);
params.push(request.stepName);
paramIndex++;
}
if (request.stepOrder !== undefined) {
fields.push(`step_order = $${paramIndex}`);
params.push(request.stepOrder);
paramIndex++;
}
if (request.tableName !== undefined) {
fields.push(`table_name = $${paramIndex}`);
params.push(request.tableName);
paramIndex++;
}
if (request.conditionJson !== undefined) {
fields.push(`condition_json = $${paramIndex}`);
params.push(
request.conditionJson ? JSON.stringify(request.conditionJson) : null
);
paramIndex++;
}
if (request.color !== undefined) {
fields.push(`color = $${paramIndex}`);
params.push(request.color);
paramIndex++;
}
if (request.positionX !== undefined) {
fields.push(`position_x = $${paramIndex}`);
params.push(request.positionX);
paramIndex++;
}
if (request.positionY !== undefined) {
fields.push(`position_y = $${paramIndex}`);
params.push(request.positionY);
paramIndex++;
}
if (fields.length === 0) {
return this.findById(id);
}
fields.push(`updated_at = NOW()`);
const query = `
UPDATE flow_step
SET ${fields.join(", ")}
WHERE id = $${paramIndex}
RETURNING *
`;
params.push(id);
const result = await db.query(query, params);
if (result.length === 0) {
return null;
}
return this.mapToFlowStep(result[0]);
}
/**
* 플로우 단계 삭제
*/
async delete(id: number): Promise<boolean> {
const query = "DELETE FROM flow_step WHERE id = $1 RETURNING id";
const result = await db.query(query, [id]);
return result.length > 0;
}
/**
* 단계 순서 재정렬
*/
async reorder(
flowDefinitionId: number,
stepOrders: { id: number; order: number }[]
): Promise<void> {
await db.transaction(async (client) => {
for (const { id, order } of stepOrders) {
await client.query(
"UPDATE flow_step SET step_order = $1, updated_at = NOW() WHERE id = $2 AND flow_definition_id = $3",
[order, id, flowDefinitionId]
);
}
});
}
/**
* DB 행을 FlowStep 객체로 변환
*/
private mapToFlowStep(row: any): FlowStep {
return {
id: row.id,
flowDefinitionId: row.flow_definition_id,
stepName: row.step_name,
stepOrder: row.step_order,
tableName: row.table_name || undefined,
conditionJson: row.condition_json as FlowConditionGroup | undefined,
color: row.color,
positionX: row.position_x,
positionY: row.position_y,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
}