Files
lcbp3/specs/06-tasks/TASK-BE-006-workflow-engine.md
admin 863a727756
Some checks failed
Spec Validation / validate-markdown (push) Has been cancelled
Spec Validation / validate-diagrams (push) Has been cancelled
Spec Validation / check-todos (push) Has been cancelled
251208:1625 Frontend: to be complete admin panel, Backend: tobe recheck all task
2025-12-08 16:25:56 +07:00

13 KiB

Task: Workflow Engine Module

Status: Completed Priority: P0 (Critical - Core Infrastructure) Estimated Effort: 10-14 days Dependencies: TASK-BE-001 (Database), TASK-BE-002 (Auth) Owner: Backend Team


📋 Overview

สร้าง Unified Workflow Engine ที่ใช้ DSL-based configuration สำหรับจัดการ Workflow ของ Correspondences, RFAs, และ Circulations


Objectives

  • DSL Parser และ Validator
  • State Machine Management
  • Workflow Instance Lifecycle
  • Transition Execution
  • History Tracking
  • Notification Integration

📝 Acceptance Criteria

  1. Definition Management:

    • Create/Update workflow from JSON DSL
    • Validate DSL syntax และ Logic
    • Version management
    • Activate/Deactivate definitions
  2. Instance Management:

    • Create instance from definition
    • Execute transitions
    • Check guards (permissions, validations)
    • Trigger effects (notifications, updates)
    • Track history
  3. Integration:

    • Used by Correspondence module
    • Used by RFA module
    • Used by Circulation module
    • Notification service integration

🛠️ Implementation Steps

1. Entities

// File: backend/src/modules/workflow-engine/entities/workflow-definition.entity.ts
@Entity('workflow_definitions')
export class WorkflowDefinition {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ length: 100 })
  name: string;

  @Column()
  version: number;

  @Column({ length: 50 })
  entity_type: string; // 'correspondence', 'rfa', 'circulation'

  @Column({ type: 'json' })
  definition: WorkflowDSL; // JSON DSL

  @Column({ default: true })
  is_active: boolean;

  @CreateDateColumn()
  created_at: Date;

  @Index(['name', 'version'], { unique: true })
  _nameVersionIndex: void;
}
// File: backend/src/modules/workflow-engine/entities/workflow-instance.entity.ts
@Entity('workflow_instances')
export class WorkflowInstance {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  definition_id: number;

  @Column({ length: 50 })
  entity_type: string;

  @Column()
  entity_id: number;

  @Column({ length: 50 })
  current_state: string;

  @Column({ type: 'json', nullable: true })
  context: any; // Runtime data

  @CreateDateColumn()
  started_at: Date;

  @Column({ type: 'timestamp', nullable: true })
  completed_at: Date;

  @ManyToOne(() => WorkflowDefinition)
  @JoinColumn({ name: 'definition_id' })
  definition: WorkflowDefinition;

  @OneToMany(() => WorkflowHistory, (history) => history.instance)
  history: WorkflowHistory[];
}
// File: backend/src/modules/workflow-engine/entities/workflow-history.entity.ts
@Entity('workflow_history')
export class WorkflowHistory {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  instance_id: number;

  @Column({ length: 50, nullable: true })
  from_state: string;

  @Column({ length: 50 })
  to_state: string;

  @Column({ length: 50 })
  action: string;

  @Column()
  actor_id: number;

  @Column({ type: 'json', nullable: true })
  metadata: any;

  @CreateDateColumn()
  transitioned_at: Date;

  @ManyToOne(() => WorkflowInstance, (instance) => instance.history)
  @JoinColumn({ name: 'instance_id' })
  instance: WorkflowInstance;

  @ManyToOne(() => User)
  @JoinColumn({ name: 'actor_id' })
  actor: User;
}

2. DSL Types

// File: backend/src/modules/workflow-engine/types/workflow-dsl.type.ts
export interface WorkflowDSL {
  name: string;
  version: number;
  entity_type: string;
  states: WorkflowState[];
  transitions: WorkflowTransition[];
}

export interface WorkflowState {
  name: string;
  type: 'initial' | 'intermediate' | 'final';
  allowed_transitions: string[];
}

export interface WorkflowTransition {
  action: string;
  from: string;
  to: string;
  guards?: Guard[];
  effects?: Effect[];
}

export interface Guard {
  type: 'permission' | 'validation' | 'condition';
  permission?: string;
  rules?: string[];
  condition?: string;
}

export interface Effect {
  type: 'notification' | 'update_entity' | 'create_log';
  template?: string;
  recipients?: string[];
  field?: string;
  value?: any;
}

3. DSL Parser

// File: backend/src/modules/workflow-engine/services/dsl-parser.service.ts
@Injectable()
export class DslParserService {
  parseDefinition(dsl: WorkflowDSL): ParsedWorkflow {
    this.validateStructure(dsl);
    this.validateStates(dsl);
    this.validateTransitions(dsl);

    return {
      states: this.parseStates(dsl.states),
      transitions: this.parseTransitions(dsl.transitions),
      stateMap: this.buildStateMap(dsl.states),
    };
  }

  private validateStructure(dsl: WorkflowDSL): void {
    if (!dsl.name || !dsl.states || !dsl.transitions) {
      throw new BadRequestException('Invalid DSL structure');
    }
  }

  private validateStates(dsl: WorkflowDSL): void {
    const initialStates = dsl.states.filter((s) => s.type === 'initial');
    if (initialStates.length !== 1) {
      throw new BadRequestException('Must have exactly one initial state');
    }

    const finalStates = dsl.states.filter((s) => s.type === 'final');
    if (finalStates.length === 0) {
      throw new BadRequestException('Must have at least one final state');
    }
  }

  private validateTransitions(dsl: WorkflowDSL): void {
    const stateNames = new Set(dsl.states.map((s) => s.name));

    for (const transition of dsl.transitions) {
      if (!stateNames.has(transition.from)) {
        throw new BadRequestException(`Unknown state: ${transition.from}`);
      }
      if (!stateNames.has(transition.to)) {
        throw new BadRequestException(`Unknown state: ${transition.to}`);
      }
    }
  }

  getInitialState(dsl: WorkflowDSL): string {
    const initialState = dsl.states.find((s) => s.type === 'initial');
    return initialState.name;
  }

  buildStateMap(states: WorkflowState[]): Map<string, WorkflowState> {
    return new Map(states.map((s) => [s.name, s]));
  }
}

4. Workflow Engine Service

// File: backend/src/modules/workflow-engine/services/workflow-engine.service.ts
@Injectable()
export class WorkflowEngineService {
  constructor(
    @InjectRepository(WorkflowDefinition)
    private defRepo: Repository<WorkflowDefinition>,
    @InjectRepository(WorkflowInstance)
    private instanceRepo: Repository<WorkflowInstance>,
    @InjectRepository(WorkflowHistory)
    private historyRepo: Repository<WorkflowHistory>,
    private dslParser: DslParserService,
    private guardExecutor: GuardExecutorService,
    private effectExecutor: EffectExecutorService,
    private dataSource: DataSource
  ) {}

  async createInstance(
    definitionName: string,
    entityType: string,
    entityId: number,
    manager?: EntityManager
  ): Promise<WorkflowInstance> {
    const repo = manager || this.instanceRepo;

    //Get active definition
    const definition = await this.defRepo.findOne({
      where: { name: definitionName, entity_type: entityType, is_active: true },
      order: { version: 'DESC' },
    });

    if (!definition) {
      throw new NotFoundException(
        `Workflow definition not found: ${definitionName}`
      );
    }

    // Get initial state
    const initialState = this.dslParser.getInitialState(definition.definition);

    // Create instance
    const instance = repo.create({
      definition_id: definition.id,
      entity_type: entityType,
      entity_id: entityId,
      current_state: initialState,
      context: {},
    });

    return repo.save(instance);
  }

  async executeTransition(
    instanceId: number,
    action: string,
    actorId: number
  ): Promise<void> {
    return this.dataSource.transaction(async (manager) => {
      // 1. Get instance
      const instance = await manager.findOne(WorkflowInstance, {
        where: { id: instanceId },
        relations: ['definition'],
      });

      if (!instance) {
        throw new NotFoundException(
          `Workflow instance not found: ${instanceId}`
        );
      }

      // 2. Find transition
      const dsl = instance.definition.definition;
      const transition = dsl.transitions.find(
        (t) => t.action === action && t.from === instance.current_state
      );

      if (!transition) {
        throw new BadRequestException(
          `Invalid transition: ${action} from ${instance.current_state}`
        );
      }

      // 3. Execute guards
      await this.guardExecutor.checkGuards(transition.guards, {
        actorId,
        instance,
      });

      // 4. Update state
      const fromState = instance.current_state;
      instance.current_state = transition.to;

      // Check if reached final state
      const toStateConfig = dsl.states.find((s) => s.name === transition.to);
      if (toStateConfig.type === 'final') {
        instance.completed_at = new Date();
      }

      await manager.save(instance);

      // 5. Record history
      await manager.save(WorkflowHistory, {
        instance_id: instanceId,
        from_state: fromState,
        to_state: transition.to,
        action,
        actor_id: actorId,
        metadata: {},
      });

      // 6. Execute effects
      await this.effectExecutor.executeEffects(transition.effects, {
        instance,
        actorId,
        manager,
      });
    });
  }

  async getInstanceHistory(instanceId: number): Promise<WorkflowHistory[]> {
    return this.historyRepo.find({
      where: { instance_id: instanceId },
      relations: ['actor'],
      order: { transitioned_at: 'ASC' },
    });
  }

  async getCurrentState(entityType: string, entityId: number): Promise<string> {
    const instance = await this.instanceRepo.findOne({
      where: { entity_type: entityType, entity_id: entityId },
      order: { started_at: 'DESC' },
    });

    return instance?.current_state || null;
  }
}

5. Guard Executor

// File: backend/src/modules/workflow-engine/services/guard-executor.service.ts
@Injectable()
export class GuardExecutorService {
  constructor(private abilityFactory: AbilityFactory) {}

  async checkGuards(guards: Guard[], context: any): Promise<void> {
    if (!guards || guards.length === 0) {
      return;
    }

    for (const guard of guards) {
      await this.checkGuard(guard, context);
    }
  }

  private async checkGuard(guard: Guard, context: any): Promise<void> {
    switch (guard.type) {
      case 'permission':
        await this.checkPermission(guard.permission, context);
        break;

      case 'validation':
        await this.checkValidation(guard.rules, context);
        break;

      case 'condition':
        await this.checkCondition(guard.condition, context);
        break;

      default:
        throw new BadRequestException(`Unknown guard type: ${guard.type}`);
    }
  }

  private async checkPermission(
    permission: string,
    context: any
  ): Promise<void> {
    const ability = await this.abilityFactory.createForUser({
      user_id: context.actorId,
    });
    const [action, subject] = permission.split('.');

    if (!ability.can(action, subject)) {
      throw new ForbiddenException(`Permission denied: ${permission}`);
    }
  }

  private async checkValidation(rules: string[], context: any): Promise<void> {
    // Implement validation rules
    // e.g., "hasAttachment", "hasRecipient"
  }

  private async checkCondition(condition: string, context: any): Promise<void> {
    // Evaluate condition expression
    // e.g., "entity.status === 'draft'"
  }
}

Testing & Verification

1. Unit Tests

describe('WorkflowEngineService', () => {
  it('should create instance with initial state', async () => {
    const instance = await service.createInstance(
      'CORRESPONDENCE_ROUTING',
      'correspondence',
      1
    );

    expect(instance.current_state).toBe('DRAFT');
  });

  it('should execute valid transition', async () => {
    await service.executeTransition(instance.id, 'SUBMIT', userId);

    const updated = await instanceRepo.findOne(instance.id);
    expect(updated.current_state).toBe('SUBMITTED');
  });

  it('should reject invalid transition', async () => {
    await expect(
      service.executeTransition(instance.id, 'INVALID_ACTION', userId)
    ).rejects.toThrow('Invalid transition');
  });
});


📦 Deliverables

  • Workflow Entities (Definition, Instance, History)
  • DSL Parser และ Validator
  • WorkflowEngineService
  • Guard Executor
  • Effect Executor
  • Example Workflow Definitions
  • Unit Tests (90% coverage)
  • Integration Tests
  • Documentation

🚨 Risks & Mitigation

Risk Impact Mitigation
DSL parsing errors High Comprehensive validation
Guard failures Medium Clear error messages
State corruption Critical Transaction-safe updates
Performance issues Medium Optimize DSL parsing, cache definitions

📌 Notes

  • DSL structure validated on save
  • Workflow definitions versioned
  • Guard checks before state changes
  • History tracked for audit trail
  • Effects executed after state update