251129:1700 update to 1.4.5

This commit is contained in:
admin
2025-11-29 16:50:34 +07:00
parent f7a43600a3
commit a78c9941be
55 changed files with 14641 additions and 2090 deletions
@@ -0,0 +1,30 @@
// File: src/modules/workflow-engine/dto/workflow-transition.dto.ts
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNotEmpty, IsObject, IsOptional, IsString } from 'class-validator';
export class WorkflowTransitionDto {
@ApiProperty({
description: 'ชื่อ Action ที่ต้องการทำ (ต้องตรงกับที่กำหนดใน DSL)',
example: 'APPROVE',
})
@IsString()
@IsNotEmpty()
action!: string;
@ApiPropertyOptional({
description: 'ความเห็นประกอบการดำเนินการ',
example: 'อนุมัติครับ ดำเนินการต่อได้เลย',
})
@IsString()
@IsOptional()
comment?: string;
@ApiPropertyOptional({
description: 'ข้อมูลเพิ่มเติมที่ต้องการแนบไปกับ Event หรือบันทึกใน Context',
example: { urgent: true, assign_to: 'user_123' },
})
@IsObject()
@IsOptional()
payload?: Record<string, any>;
}
@@ -1,37 +1,58 @@
// File: src/modules/workflow-engine/entities/workflow-definition.entity.ts
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
Unique,
UpdateDateColumn,
} from 'typeorm';
/**
* เก็บแม่แบบ (Blueprint) ของ Workflow
* 1 Workflow Code (เช่น RFA) สามารถมีได้หลาย Version
*/
@Entity('workflow_definitions')
@Index(['workflow_code', 'is_active', 'version'])
@Unique(['workflow_code', 'version']) // ป้องกัน Version ซ้ำใน Workflow เดียวกัน
@Index(['workflow_code', 'is_active', 'version']) // เพื่อการ Query หา Active Version ล่าสุดได้เร็ว
export class WorkflowDefinition {
@PrimaryGeneratedColumn('uuid')
id!: string; // เพิ่ม !
id!: string;
@Column({ length: 50, comment: 'รหัส Workflow เช่น RFA, CORR' })
workflow_code!: string; // เพิ่ม !
@Column({ length: 50, comment: 'รหัส Workflow เช่น RFA, CORR, LEAVE_REQ' })
workflow_code!: string;
@Column({ type: 'int', default: 1, comment: 'หมายเลข Version' })
version!: number; // เพิ่ม !
@Column({
type: 'int',
default: 1,
comment: 'หมายเลข Version (Running sequence)',
})
version!: number;
@Column({ type: 'json', comment: 'นิยาม Workflow ต้นฉบับ' })
dsl!: any; // เพิ่ม !
@Column({ type: 'text', nullable: true, comment: 'คำอธิบายเพิ่มเติม' })
description?: string;
@Column({ type: 'json', comment: 'โครงสร้างที่ Compile แล้ว' })
compiled!: any; // เพิ่ม !
@Column({
type: 'json',
comment: 'Raw DSL ที่ User/Admin เขียน (เก็บไว้เพื่อดูหรือแก้ไข)',
})
dsl!: any; // ควรตรงกับ RawWorkflowDSL interface
@Column({ default: true, comment: 'สถานะการใช้งาน' })
is_active!: boolean; // เพิ่ม !
@Column({
type: 'json',
comment:
'Compiled JSON Structure ที่ผ่านการ Validate และ Optimize สำหรับ Runtime Engine แล้ว',
})
compiled!: any; // ควรตรงกับ CompiledWorkflow interface
@CreateDateColumn()
created_at!: Date; // เพิ่ม !
@Column({ default: true, comment: 'สถานะการใช้งาน (Soft Disable)' })
is_active!: boolean;
@UpdateDateColumn()
updated_at!: Date; // เพิ่ม !
@CreateDateColumn({ name: 'created_at' })
created_at!: Date;
@UpdateDateColumn({ name: 'updated_at' })
updated_at!: Date;
}
@@ -1,15 +1,23 @@
// File: src/modules/workflow-engine/entities/workflow-history.entity.ts
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { WorkflowInstance } from './workflow-instance.entity';
/**
* เก็บประวัติการเปลี่ยนสถานะ (Audit Trail)
* สำคัญมากสำหรับการตรวจสอบย้อนหลัง (Who did What, When)
*/
@Entity('workflow_histories')
@Index(['instanceId']) // ค้นหาประวัติของ Instance นี้
@Index(['actionByUserId']) // ค้นหาว่า User คนนี้ทำอะไรไปบ้าง
export class WorkflowHistory {
@PrimaryGeneratedColumn('uuid')
id!: string;
@@ -21,23 +29,32 @@ export class WorkflowHistory {
@Column({ name: 'instance_id' })
instanceId!: string;
@Column({ name: 'from_state', length: 50 })
@Column({ name: 'from_state', length: 50, comment: 'สถานะต้นทาง' })
fromState!: string;
@Column({ name: 'to_state', length: 50 })
@Column({ name: 'to_state', length: 50, comment: 'สถานะปลายทาง' })
toState!: string;
@Column({ length: 50 })
@Column({ length: 50, comment: 'Action ที่ User กด (เช่น APPROVE, REJECT)' })
action!: string;
@Column({ name: 'action_by_user_id', nullable: true })
actionByUserId?: number; // User ID ของผู้ดำเนินการ
@Column({
name: 'action_by_user_id',
nullable: true,
comment: 'User ID ผู้ดำเนินการ (Nullable กรณี System Auto)',
})
actionByUserId?: number;
@Column({ type: 'text', nullable: true })
@Column({ type: 'text', nullable: true, comment: 'ความเห็นประกอบการอนุมัติ' })
comment?: string;
@Column({ type: 'json', nullable: true })
metadata?: Record<string, any>; // เก็บข้อมูลเพิ่มเติม เช่น Snapshot ของ Context ณ ตอนนั้น
// Snapshot ข้อมูล ณ เวลาที่เปลี่ยนสถานะ เพื่อเป็นหลักฐานหาก Context เปลี่ยนในอนาคต
@Column({
type: 'json',
nullable: true,
comment: 'Snapshot of Context or Metadata',
})
metadata?: Record<string, any>;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@@ -1,4 +1,5 @@
// File: src/modules/workflow-engine/entities/workflow-instance.entity.ts
import {
Column,
CreateDateColumn,
@@ -12,20 +13,23 @@ import {
import { WorkflowDefinition } from './workflow-definition.entity';
export enum WorkflowStatus {
ACTIVE = 'ACTIVE',
COMPLETED = 'COMPLETED',
CANCELLED = 'CANCELLED',
TERMINATED = 'TERMINATED',
ACTIVE = 'ACTIVE', // กำลังดำเนินการ
COMPLETED = 'COMPLETED', // จบกระบวนการ (ถึง Terminal State)
CANCELLED = 'CANCELLED', // ถูกยกเลิกกลางคัน
TERMINATED = 'TERMINATED', // ถูกบังคับจบโดยระบบ หรือ Error
}
/**
* เก็บสถานะการเดินเรื่องของเอกสารแต่ละใบ (Runtime State)
*/
@Entity('workflow_instances')
@Index(['entityType', 'entityId']) // Index สำหรับค้นหาตามเอกสาร
@Index(['currentState']) // Index สำหรับ Filter ตามสถานะ
@Index(['entityType', 'entityId']) // เพื่อค้นหาว่าเอกสารนี้ (เช่น RFA-001) อยู่ขั้นตอนไหน
@Index(['currentState']) // เพื่อ Dashboard: "มีงานค้างที่ขั้นตอนไหนบ้าง"
export class WorkflowInstance {
@PrimaryGeneratedColumn('uuid')
id!: string;
// เชื่อมโยงกับ Definition ที่ใช้ตอนสร้าง Instance นี้
// ผูกกับ Definition เพื่อรู้ว่าใช้กฎชุดไหน (Version ไหน)
@ManyToOne(() => WorkflowDefinition)
@JoinColumn({ name: 'definition_id' })
definition!: WorkflowDefinition;
@@ -33,25 +37,39 @@ export class WorkflowInstance {
@Column({ name: 'definition_id' })
definitionId!: string;
// Polymorphic Relation: เชื่อมกับเอกสารได้หลายประเภท (RFA, CORR, etc.)
@Column({ name: 'entity_type', length: 50 })
// Polymorphic Relation: เชื่อมกับเอกสารได้หลายประเภท (RFA, CORR, etc.) โดยไม่ต้อง Foreign Key จริง
@Column({
name: 'entity_type',
length: 50,
comment: 'ประเภทเอกสาร เช่น rfa, correspondence',
})
entityType!: string;
@Column({ name: 'entity_id', length: 50 })
entityId!: string; // รองรับทั้ง ID แบบ Int และ UUID (เก็บเป็น String)
@Column({
name: 'entity_id',
length: 50,
comment: 'ID ของเอกสาร (String/UUID)',
})
entityId!: string;
@Column({ name: 'current_state', length: 50 })
@Column({
name: 'current_state',
length: 50,
comment: 'ชื่อ State ปัจจุบัน เช่น DRAFT, IN_REVIEW',
})
currentState!: string;
@Column({
type: 'enum',
enum: WorkflowStatus,
default: WorkflowStatus.ACTIVE,
comment: 'สถานะภาพรวมของ Instance',
})
status!: WorkflowStatus;
// Context เฉพาะของ Instance นี้ (เช่น ตัวแปรที่ส่งต่อระหว่าง State)
@Column({ type: 'json', nullable: true })
// Context:ก็บตัวแปรที่จำเป็นสำหรับการตัดสินใจใน Workflow
// เช่น { "amount": 500000, "requester_role": "ENGINEER", "approver_ids": [1, 2] }
@Column({ type: 'json', nullable: true, comment: 'Runtime Context Data' })
context?: Record<string, any>;
@CreateDateColumn({ name: 'created_at' })
@@ -1,181 +1,255 @@
// File: src/modules/workflow-engine/workflow-dsl.service.ts
import { BadRequestException, Injectable } from '@nestjs/common';
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
export interface WorkflowState {
// ==========================================
// 1. Interfaces for RAW DSL (Input from User)
// ==========================================
export interface RawWorkflowDSL {
workflow: string;
version?: number;
description?: string;
states: RawState[];
}
export interface RawState {
name: string;
initial?: boolean;
terminal?: boolean;
transitions?: Record<string, TransitionRule>;
on?: Record<string, RawTransition>;
}
export interface TransitionRule {
export interface RawTransition {
to: string;
requirements?: RequirementRule[];
events?: EventRule[];
require?: {
role?: string | string[];
user?: string;
};
condition?: string; // JavaScript Expression string
events?: RawEvent[];
}
export interface RequirementRule {
role?: string;
user?: string;
condition?: string;
}
export interface EventRule {
type: 'notify' | 'webhook' | 'update_status';
export interface RawEvent {
type: 'notify' | 'webhook' | 'assign' | 'auto_action';
target?: string;
template?: string;
payload?: any;
}
// ==========================================
// 2. Interfaces for COMPILED Schema (Optimized for Runtime)
// ==========================================
export interface CompiledWorkflow {
workflow: string;
version: string | number;
states: Record<string, WorkflowState>;
version: number;
initialState: string; // Optimize: เก็บชื่อ Initial State ไว้เลย ไม่ต้อง loop หา
states: Record<string, CompiledState>;
}
export interface CompiledState {
terminal: boolean;
transitions: Record<string, CompiledTransition>;
}
export interface CompiledTransition {
to: string;
requirements: {
roles: string[];
userId?: string;
};
condition?: string;
events: RawEvent[];
}
@Injectable()
export class WorkflowDslService {
/**
* คอมไพล์ DSL Input ให้เป็น Standard Execution Tree
*/
compile(dsl: any): CompiledWorkflow {
if (!dsl || typeof dsl !== 'object') {
throw new BadRequestException('DSL must be a valid JSON object.');
}
private readonly logger = new Logger(WorkflowDslService.name);
if (!dsl.states || !Array.isArray(dsl.states)) {
throw new BadRequestException(
'DSL syntax error: "states" array is required.',
);
}
/**
* [Compile Time]
* แปลง Raw DSL เป็น Compiled Structure พร้อม Validation
*/
compile(dsl: RawWorkflowDSL): CompiledWorkflow {
this.validateSchemaStructure(dsl);
const compiled: CompiledWorkflow = {
workflow: dsl.workflow || 'UNKNOWN',
workflow: dsl.workflow,
version: dsl.version || 1,
initialState: '',
states: {},
};
const stateMap = new Set<string>();
const definedStates = new Set<string>(dsl.states.map((s) => s.name));
let initialFound = false;
// 1. Process States
for (const rawState of dsl.states) {
if (!rawState.name) {
throw new BadRequestException(
'DSL syntax error: All states must have a "name".',
);
if (rawState.initial) {
if (initialFound) {
throw new BadRequestException(
`DSL Error: Multiple initial states found (at "${rawState.name}").`,
);
}
compiled.initialState = rawState.name;
initialFound = true;
}
stateMap.add(rawState.name);
const normalizedState: WorkflowState = {
initial: !!rawState.initial,
const compiledState: CompiledState = {
terminal: !!rawState.terminal,
transitions: {},
};
// 2. Process Transitions
if (rawState.on) {
for (const [action, rule] of Object.entries(rawState.on)) {
const rawRule = rule as any;
normalizedState.transitions![action] = {
to: rawRule.to,
requirements: rawRule.require || [],
events: rawRule.events || [],
// Validation: Target state must exist
if (!definedStates.has(rule.to)) {
throw new BadRequestException(
`DSL Error: State "${rawState.name}" transitions via "${action}" to unknown state "${rule.to}".`,
);
}
compiledState.transitions[action] = {
to: rule.to,
requirements: {
roles: rule.require?.role
? Array.isArray(rule.require.role)
? rule.require.role
: [rule.require.role]
: [],
userId: rule.require?.user,
},
condition: rule.condition,
events: rule.events || [],
};
}
} else if (!rawState.terminal) {
this.logger.warn(
`State "${rawState.name}" is not terminal but has no transitions.`,
);
}
compiled.states[rawState.name] = normalizedState;
compiled.states[rawState.name] = compiledState;
}
this.validateIntegrity(compiled, stateMap);
if (!initialFound) {
throw new BadRequestException('DSL Error: No initial state defined.');
}
return compiled;
}
private validateIntegrity(compiled: CompiledWorkflow, stateMap: Set<string>) {
let hasInitial = false;
for (const [stateName, state] of Object.entries(compiled.states)) {
if (state.initial) {
if (hasInitial)
throw new BadRequestException(
`DSL Error: Multiple initial states found.`,
);
hasInitial = true;
}
if (state.transitions) {
for (const [action, rule] of Object.entries(state.transitions)) {
if (!stateMap.has(rule.to)) {
throw new BadRequestException(
`DSL Error: State "${stateName}" transitions via "${action}" to unknown state "${rule.to}".`,
);
}
}
}
}
if (!hasInitial) {
throw new BadRequestException('DSL Error: No initial state defined.');
}
}
/**
* [Runtime]
* ประมวลผล Action และคืนค่า State ถัดไป
*/
evaluate(
compiled: CompiledWorkflow,
currentState: string,
action: string,
context: any = {}, // Default empty object
): { nextState: string; events: EventRule[] } {
context: any = {},
): { nextState: string; events: RawEvent[] } {
const stateConfig = compiled.states[currentState];
// 1. Validate State Existence
if (!stateConfig) {
throw new BadRequestException(
`Runtime Error: Current state "${currentState}" not found in definition.`,
`Runtime Error: Current state "${currentState}" is invalid.`,
);
}
// 2. Check if terminal
if (stateConfig.terminal) {
throw new BadRequestException(
`Runtime Error: Cannot transition from terminal state "${currentState}".`,
);
}
const transition = stateConfig.transitions?.[action];
// 3. Find Transition
const transition = stateConfig.transitions[action];
if (!transition) {
const allowed = Object.keys(stateConfig.transitions).join(', ');
throw new BadRequestException(
`Runtime Error: Action "${action}" is not allowed from state "${currentState}". Available actions: ${Object.keys(stateConfig.transitions || {}).join(', ')}`,
`Invalid Action: "${action}" is not allowed from "${currentState}". Allowed: [${allowed}]`,
);
}
if (transition.requirements && transition.requirements.length > 0) {
this.checkRequirements(transition.requirements, context);
// 4. Validate Requirements (RBAC)
this.checkRequirements(transition.requirements, context);
// 5. Evaluate Condition (Dynamic Logic)
if (transition.condition) {
const isMet = this.evaluateCondition(transition.condition, context);
if (!isMet) {
throw new BadRequestException(
'Condition Failed: The criteria for this transition are not met.',
);
}
}
return {
nextState: transition.to,
events: transition.events || [],
events: transition.events,
};
}
private checkRequirements(requirements: RequirementRule[], context: any) {
const safeContext = context || {};
const userRoles = safeContext.roles || [];
const userId = safeContext.userId;
// --------------------------------------------------------
// Private Helpers
// --------------------------------------------------------
const isAllowed = requirements.some((req) => {
if (req.role) {
return userRoles.includes(req.role);
}
if (req.user) {
return userId === req.user;
}
// Future: Add Condition Logic Evaluation here
return false;
});
if (!isAllowed) {
private validateSchemaStructure(dsl: any) {
if (!dsl || typeof dsl !== 'object') {
throw new BadRequestException('DSL must be a JSON object.');
}
if (!dsl.workflow || !dsl.states || !Array.isArray(dsl.states)) {
throw new BadRequestException(
'Access Denied: You do not meet the requirements for this action.',
'DSL Error: Missing required fields (workflow, states).',
);
}
}
private checkRequirements(
req: CompiledTransition['requirements'],
context: any,
) {
const userRoles: string[] = context.roles || [];
const userId: string | number = context.userId;
// Check Roles (OR logic inside array)
if (req.roles.length > 0) {
const hasRole = req.roles.some((r) => userRoles.includes(r));
if (!hasRole) {
throw new BadRequestException(
`Access Denied: Required roles [${req.roles.join(', ')}]`,
);
}
}
// Check Specific User
if (req.userId && String(req.userId) !== String(userId)) {
throw new BadRequestException('Access Denied: User mismatch.');
}
}
/**
* Evaluate simple JS expression securely
* NOTE: In production, use a safe parser like 'json-logic-js' or vm2
* For this phase, we use a simple Function constructor with restricted scope.
*/
private evaluateCondition(expression: string, context: any): boolean {
try {
// Simple guard against malicious code (basic)
if (expression.includes('process') || expression.includes('require')) {
throw new Error('Unsafe expression detected');
}
// Create a function that returns the expression result
// "context" is available inside the expression
const func = new Function('context', `return ${expression};`);
return !!func(context);
} catch (error: any) {
this.logger.error(`Condition Error: "${expression}" -> ${error.message}`);
return false; // Fail safe
}
}
}
@@ -5,58 +5,103 @@ import {
Controller,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
Request,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { CreateWorkflowDefinitionDto } from './dto/create-workflow-definition.dto';
import { EvaluateWorkflowDto } from './dto/evaluate-workflow.dto';
import { GetAvailableActionsDto } from './dto/get-available-actions.dto';
import { UpdateWorkflowDefinitionDto } from './dto/update-workflow-definition.dto';
import {
ApiBearerAuth,
ApiOperation,
ApiParam,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
// Services
import { WorkflowEngineService } from './workflow-engine.service';
@ApiTags('Workflow Engine (DSL)')
// DTOs
import { CreateWorkflowDefinitionDto } from './dto/create-workflow-definition.dto';
import { EvaluateWorkflowDto } from './dto/evaluate-workflow.dto';
import { UpdateWorkflowDefinitionDto } from './dto/update-workflow-definition.dto';
import { WorkflowTransitionDto } from './dto/workflow-transition.dto';
// Guards & Decorators (อ้างอิงตามโครงสร้าง src/common ในแผนงาน)
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RbacGuard } from '../../common/guards/rbac.guard';
@ApiTags('Workflow Engine')
@ApiBearerAuth() // ระบุว่าต้องใช้ Token ใน Swagger
@Controller('workflow-engine')
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, RbacGuard) // บังคับ Login และตรวจสอบสิทธิ์ทุก Request
export class WorkflowEngineController {
constructor(private readonly workflowService: WorkflowEngineService) {}
// =================================================================
// Definition Management (Admin / Developer)
// =================================================================
@Post('definitions')
@ApiOperation({ summary: 'Create or Update Workflow Definition (DSL)' })
@ApiResponse({ status: 201, description: 'Workflow compiled and saved.' })
@ApiOperation({ summary: 'สร้าง Workflow Definition ใหม่ (Auto Versioning)' })
@ApiResponse({ status: 201, description: 'Created successfully' })
// ใช้ Permission 'system.manage_all' (Admin) หรือสร้าง permission ใหม่ 'workflow.manage' ในอนาคต
@RequirePermission('system.manage_all')
async createDefinition(@Body() dto: CreateWorkflowDefinitionDto) {
return this.workflowService.createDefinition(dto);
}
@Post('evaluate')
@ApiOperation({
summary: 'Evaluate transition (Run logic without saving state)',
})
async evaluate(@Body() dto: EvaluateWorkflowDto) {
return this.workflowService.evaluate(dto);
}
@Get('actions')
@ApiOperation({ summary: 'Get available actions for current state' })
async getAvailableActions(@Query() query: GetAvailableActionsDto) {
return this.workflowService.getAvailableActions(
query.workflow_code,
query.current_state,
);
}
@Patch('definitions/:id')
@ApiOperation({
summary: 'Update workflow status or details (DSL Re-compile)',
})
@ApiOperation({ summary: 'แก้ไข Workflow Definition (Re-compile DSL)' })
@RequirePermission('system.manage_all')
async updateDefinition(
@Param('id', ParseUUIDPipe) id: string, // เพิ่ม ParseUUIDPipe เพื่อ Validate ID
@Param('id') id: string,
@Body() dto: UpdateWorkflowDefinitionDto,
) {
return this.workflowService.update(id, dto);
}
@Post('evaluate')
@ApiOperation({ summary: 'ทดสอบ Logic Workflow (Dry Run) ไม่บันทึกข้อมูล' })
@RequirePermission('system.manage_all')
async evaluate(@Body() dto: EvaluateWorkflowDto) {
return this.workflowService.evaluate(dto);
}
// =================================================================
// Runtime Engine (User Actions)
// =================================================================
@Post('instances/:id/transition')
@ApiOperation({ summary: 'สั่งเปลี่ยนสถานะเอกสาร (User Action)' })
@ApiParam({ name: 'id', description: 'Workflow Instance ID (UUID)' })
// Permission จะถูกตรวจสอบ Dynamic ภายใน Service ตาม State ของ Workflow แต่ขั้นต้นต้องมีสิทธิ์ทำงาน Workflow
@RequirePermission('workflow.action_review')
async processTransition(
@Param('id') instanceId: string,
@Body() dto: WorkflowTransitionDto,
@Request() req: any,
) {
// ดึง User ID จาก Token (req.user มาจาก JwtStrategy)
const userId = req.user?.userId;
return this.workflowService.processTransition(
instanceId,
dto.action,
userId,
dto.comment,
dto.payload,
);
}
@Get('instances/:id/actions')
@ApiOperation({
summary: 'ดึงรายการปุ่ม Action ที่สามารถกดได้ ณ สถานะปัจจุบัน',
})
@RequirePermission('document.view') // ผู้ที่มีสิทธิ์ดูเอกสาร ควรดู Action ได้
async getAvailableActions(@Param('id') instanceId: string) {
// Note: Logic การดึง Action ตาม Instance ID จะถูก Implement ใน Task ถัดไป
return { message: 'Pending implementation in Service layer' };
}
}
@@ -2,23 +2,31 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { WorkflowDefinition } from './entities/workflow-definition.entity';
import { WorkflowHistory } from './entities/workflow-history.entity'; // [New]
import { WorkflowInstance } from './entities/workflow-instance.entity'; // [New]
import { WorkflowDslService } from './workflow-dsl.service';
import { WorkflowEngineController } from './workflow-engine.controller';
import { WorkflowEngineService } from './workflow-engine.service';
// Entities
import { WorkflowDefinition } from './entities/workflow-definition.entity';
import { WorkflowHistory } from './entities/workflow-history.entity';
import { WorkflowInstance } from './entities/workflow-instance.entity';
// Services
import { WorkflowDslService } from './workflow-dsl.service';
import { WorkflowEngineService } from './workflow-engine.service';
import { WorkflowEventService } from './workflow-event.service'; // [NEW]
// Controllers
import { UserModule } from '../user/user.module';
import { WorkflowEngineController } from './workflow-engine.controller';
@Module({
imports: [
TypeOrmModule.forFeature([
WorkflowDefinition,
WorkflowInstance, // [New]
WorkflowHistory, // [New]
WorkflowInstance,
WorkflowHistory,
]),
UserModule,
],
controllers: [WorkflowEngineController],
providers: [WorkflowEngineService, WorkflowDslService],
exports: [WorkflowEngineService],
providers: [WorkflowEngineService, WorkflowDslService, WorkflowEventService],
exports: [WorkflowEngineService], // Export Service ให้ Module อื่น (Correspondence, RFA) เรียกใช้
})
export class WorkflowEngineModule {}
@@ -22,6 +22,7 @@ import { CreateWorkflowDefinitionDto } from './dto/create-workflow-definition.dt
import { EvaluateWorkflowDto } from './dto/evaluate-workflow.dto';
import { UpdateWorkflowDefinitionDto } from './dto/update-workflow-definition.dto';
import { CompiledWorkflow, WorkflowDslService } from './workflow-dsl.service';
import { WorkflowEventService } from './workflow-event.service'; // [NEW] Import Event Service
// Legacy Interface (Backward Compatibility)
export enum WorkflowAction {
@@ -49,6 +50,7 @@ export class WorkflowEngineService {
@InjectRepository(WorkflowHistory)
private readonly historyRepo: Repository<WorkflowHistory>,
private readonly dslService: WorkflowDslService,
private readonly eventService: WorkflowEventService, // [NEW] Inject Service
private readonly dataSource: DataSource, // ใช้สำหรับ Transaction
) {}
@@ -166,9 +168,9 @@ export class WorkflowEngineService {
// 2. หา Initial State จาก Compiled Structure
const compiled: CompiledWorkflow = definition.compiled;
const initialState = Object.keys(compiled.states).find(
(key) => compiled.states[key].initial,
);
// [FIX] ใช้ initialState จาก Root Property โดยตรง (ตามที่ Optimize ใน DSL Service)
// เพราะ CompiledState ใน states map ไม่มี property 'initial' แล้ว
const initialState = compiled.initialState;
if (!initialState) {
throw new BadRequestException(
@@ -193,6 +195,25 @@ export class WorkflowEngineService {
return savedInstance;
}
/**
* ดึงข้อมูล Workflow Instance ตาม ID
* ใช้สำหรับการตรวจสอบสถานะหรือซิงค์ข้อมูลกลับไปยัง Module หลัก
*/
async getInstanceById(instanceId: string): Promise<WorkflowInstance> {
const instance = await this.instanceRepo.findOne({
where: { id: instanceId },
relations: ['definition'],
});
if (!instance) {
throw new NotFoundException(
`Workflow Instance "${instanceId}" not found`,
);
}
return instance;
}
/**
* ดำเนินการเปลี่ยนสถานะ (Transition) ของ Instance จริงแบบ Transactional
*/
@@ -207,6 +228,9 @@ export class WorkflowEngineService {
await queryRunner.connect();
await queryRunner.startTransaction();
let eventsToDispatch: any[] = [];
let updatedContext: any = {};
try {
// 1. Lock Instance เพื่อป้องกัน Race Condition (Pessimistic Write Lock)
const instance = await queryRunner.manager.findOne(WorkflowInstance, {
@@ -268,25 +292,29 @@ export class WorkflowEngineService {
});
await queryRunner.manager.save(history);
// 5. Trigger Events (Integration Point)
// ในอนาคตสามารถ Inject NotificationService มาเรียกตรงนี้ได้
if (evaluation.events && evaluation.events.length > 0) {
this.logger.log(
`Triggering ${evaluation.events.length} events for instance ${instanceId}`,
);
// await this.eventHandler.handle(evaluation.events);
}
await queryRunner.commitTransaction();
// [NEW] เก็บค่าไว้ Dispatch หลัง Commit
eventsToDispatch = evaluation.events;
updatedContext = context;
this.logger.log(
`Transition: ${instanceId} [${fromState}] --${action}--> [${toState}] by User:${userId}`,
);
// [NEW] Dispatch Events (Async) ผ่าน WorkflowEventService
if (eventsToDispatch && eventsToDispatch.length > 0) {
this.eventService.dispatchEvents(
instance.id,
eventsToDispatch,
updatedContext,
);
}
return {
success: true,
nextState: toState,
events: evaluation.events,
events: eventsToDispatch,
isCompleted: instance.status === WorkflowStatus.COMPLETED,
};
} catch (err) {
@@ -0,0 +1,96 @@
// File: src/modules/workflow-engine/workflow-event.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { RawEvent } from './workflow-dsl.service';
// Interface สำหรับ External Services ที่จะมารับ Event ต่อ
// (ในอนาคตควรใช้ NestJS Event Emitter เพื่อ Decouple อย่างสมบูรณ์)
export interface WorkflowEventHandler {
handleNotification(
target: string,
template: string,
payload: any,
): Promise<void>;
handleWebhook(url: string, payload: any): Promise<void>;
handleAutoAction(instanceId: string, action: string): Promise<void>;
}
@Injectable()
export class WorkflowEventService {
private readonly logger = new Logger(WorkflowEventService.name);
// สามารถ Inject NotificationService หรือ HttpService เข้ามาได้ตรงนี้
// constructor(private readonly notificationService: NotificationService) {}
/**
* ประมวลผลรายการ Events ที่เกิดจากการเปลี่ยนสถานะ
*/
async dispatchEvents(
instanceId: string,
events: RawEvent[],
context: Record<string, any>,
) {
if (!events || events.length === 0) return;
this.logger.log(
`Dispatching ${events.length} events for Instance ${instanceId}`,
);
// ทำแบบ Async ไม่รอผล (Fire-and-forget) เพื่อไม่ให้กระทบ Response Time ของ User
Promise.allSettled(
events.map((event) =>
this.processSingleEvent(instanceId, event, context),
),
).then((results) => {
// Log errors if any
results.forEach((res, idx) => {
if (res.status === 'rejected') {
this.logger.error(`Failed to process event [${idx}]: ${res.reason}`);
}
});
});
}
private async processSingleEvent(
instanceId: string,
event: RawEvent,
context: any,
) {
try {
switch (event.type) {
case 'notify':
await this.handleNotify(event, context);
break;
case 'webhook':
await this.handleWebhook(event, context);
break;
case 'auto_action':
// Logic สำหรับ Auto Transition (เช่น ถ้าผ่านเงื่อนไข ให้ไปต่อเลย)
this.logger.log(`Auto Action triggered for ${instanceId}`);
break;
default:
this.logger.warn(`Unknown event type: ${event.type}`);
}
} catch (error) {
this.logger.error(`Error processing event ${event.type}: ${error}`);
throw error;
}
}
// --- Handlers ---
private async handleNotify(event: RawEvent, context: any) {
// Mockup: ในของจริงจะเรียก NotificationService.send()
// const recipients = this.resolveRecipients(event.target, context);
this.logger.log(
`[EVENT] Notify target: "${event.target}" | Template: "${event.template}"`,
);
}
private async handleWebhook(event: RawEvent, context: any) {
// Mockup: เรียก HttpService.post()
this.logger.log(
`[EVENT] Webhook to: "${event.target}" | Payload: ${JSON.stringify(event.payload)}`,
);
}
}