251206:1400 version 1.5.1

This commit is contained in:
admin
2025-12-06 14:42:32 +07:00
parent 7dce419745
commit 0aaa139145
34 changed files with 4652 additions and 251 deletions

View File

@@ -6,6 +6,8 @@ import { ConfigModule } from '@nestjs/config';
import { DocumentNumberingService } from './document-numbering.service';
import { DocumentNumberFormat } from './entities/document-number-format.entity';
import { DocumentNumberCounter } from './entities/document-number-counter.entity';
import { DocumentNumberAudit } from './entities/document-number-audit.entity'; // [P0-4]
import { DocumentNumberError } from './entities/document-number-error.entity'; // [P0-4]
// Master Entities ที่ต้องใช้ Lookup
import { Project } from '../project/entities/project.entity';
@@ -20,6 +22,8 @@ import { CorrespondenceSubType } from '../correspondence/entities/correspondence
TypeOrmModule.forFeature([
DocumentNumberFormat,
DocumentNumberCounter,
DocumentNumberAudit, // [P0-4]
DocumentNumberError, // [P0-4]
Project,
Organization,
CorrespondenceType,

View File

@@ -25,6 +25,8 @@ import { Organization } from '../project/entities/organization.entity';
import { CorrespondenceType } from '../correspondence/entities/correspondence-type.entity';
import { Discipline } from '../master/entities/discipline.entity';
import { CorrespondenceSubType } from '../correspondence/entities/correspondence-sub-type.entity';
import { DocumentNumberAudit } from './entities/document-number-audit.entity'; // [P0-4]
import { DocumentNumberError } from './entities/document-number-error.entity'; // [P0-4]
// Interfaces
import {
@@ -53,8 +55,12 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
private disciplineRepo: Repository<Discipline>,
@InjectRepository(CorrespondenceSubType)
private subTypeRepo: Repository<CorrespondenceSubType>,
@InjectRepository(DocumentNumberAudit) // [P0-4]
private auditRepo: Repository<DocumentNumberAudit>,
@InjectRepository(DocumentNumberError) // [P0-4]
private errorRepo: Repository<DocumentNumberError>,
private configService: ConfigService,
private configService: ConfigService
) {}
onModuleInit() {
@@ -74,7 +80,7 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
});
this.logger.log(
`Document Numbering Service initialized (Redis: ${host}:${port})`,
`Document Numbering Service initialized (Redis: ${host}:${port})`
);
}
@@ -95,7 +101,7 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
// 2. ดึง Format Template
const formatTemplate = await this.getFormatTemplate(
ctx.projectId,
ctx.typeId,
ctx.typeId
);
// 3. สร้าง Resource Key สำหรับ Lock (ละเอียดถึงระดับ Discipline)
@@ -142,12 +148,30 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
await this.counterRepo.save(counter);
// E. Format Result
return this.replaceTokens(formatTemplate, tokens, counter.lastNumber);
const generatedNumber = this.replaceTokens(
formatTemplate,
tokens,
counter.lastNumber
);
// [P0-4] F. Audit Logging
await this.logAudit({
generatedNumber,
counterKey: resourceKey,
templateUsed: formatTemplate,
sequenceNumber: counter.lastNumber,
userId: ctx.userId,
ipAddress: ctx.ipAddress,
retryCount: i,
lockWaitMs: 0, // TODO: calculate actual wait time
});
return generatedNumber;
} catch (err) {
// ถ้า Version ไม่ตรง (มีคนแทรกได้ในเสี้ยววินาที) ให้ Retry
if (err instanceof OptimisticLockVersionMismatchError) {
this.logger.warn(
`Optimistic Lock Collision for ${resourceKey}. Retrying...`,
`Optimistic Lock Collision for ${resourceKey}. Retrying...`
);
continue;
}
@@ -156,10 +180,22 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
}
throw new InternalServerErrorException(
'Failed to generate document number after retries.',
'Failed to generate document number after retries.'
);
} catch (error) {
this.logger.error(`Error generating number for ${resourceKey}`, error);
// [P0-4] Log error
await this.logError({
counterKey: resourceKey,
errorType: this.classifyError(error),
errorMessage: error.message,
stackTrace: error.stack,
userId: ctx.userId,
ipAddress: ctx.ipAddress,
context: ctx,
}).catch(() => {}); // Don't throw if error logging fails
throw error;
} finally {
// 🔓 Release Lock
@@ -174,7 +210,7 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
*/
private async resolveTokens(
ctx: GenerateNumberContext,
year: number,
year: number
): Promise<DecodedTokens> {
const [project, org, type] = await Promise.all([
this.projectRepo.findOne({ where: { id: ctx.projectId } }),
@@ -210,6 +246,17 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
// ใน Req 6B ตัวอย่างใช้ 2568 (พ.ศ.) ดังนั้นต้องแปลง
const yearTh = (year + 543).toString();
// [P1-4] Resolve recipient organization
let recipientCode = '';
if (ctx.recipientOrgId) {
const recipient = await this.orgRepo.findOne({
where: { id: ctx.recipientOrgId },
});
if (recipient) {
recipientCode = recipient.organizationCode;
}
}
return {
projectCode: project.projectCode,
orgCode: org.organizationCode,
@@ -219,6 +266,7 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
subTypeNumber,
year: yearTh,
yearShort: yearTh.slice(-2), // 68
recipientCode, // [P1-4]
};
}
@@ -227,7 +275,7 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
*/
private async getFormatTemplate(
projectId: number,
typeId: number,
typeId: number
): Promise<string> {
const format = await this.formatRepo.findOne({
where: { projectId, correspondenceTypeId: typeId },
@@ -242,7 +290,7 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
private replaceTokens(
template: string,
tokens: DecodedTokens,
seq: number,
seq: number
): string {
let result = template;
@@ -253,6 +301,7 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
'{DISCIPLINE}': tokens.disciplineCode,
'{SUBTYPE}': tokens.subTypeCode,
'{SUBTYPE_NUM}': tokens.subTypeNumber, // [Req 6B] For Transmittal/RFA
'{RECIPIENT}': tokens.recipientCode, // [P1-4] Recipient organization
'{YEAR}': tokens.year,
'{YEAR_SHORT}': tokens.yearShort,
};
@@ -271,4 +320,50 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
return result;
}
/**
* [P0-4] Log successful number generation to audit table
*/
private async logAudit(
auditData: Partial<DocumentNumberAudit>
): Promise<void> {
try {
await this.auditRepo.save(auditData);
} catch (error) {
this.logger.error('Failed to log audit', error);
// Don't throw - audit failure shouldn't block number generation
}
}
/**
* [P0-4] Log error to error table
*/
private async logError(
errorData: Partial<DocumentNumberError>
): Promise<void> {
try {
await this.errorRepo.save(errorData);
} catch (error) {
this.logger.error('Failed to log error', error);
}
}
/**
* [P0-4] Classify error type for logging
*/
private classifyError(error: any): string {
if (error.message?.includes('lock') || error.message?.includes('Lock')) {
return 'LOCK_TIMEOUT';
}
if (error instanceof OptimisticLockVersionMismatchError) {
return 'VERSION_CONFLICT';
}
if (error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT') {
return 'REDIS_ERROR';
}
if (error.name === 'QueryFailedError') {
return 'DB_ERROR';
}
return 'VALIDATION_ERROR';
}
}

View File

@@ -0,0 +1,42 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
} from 'typeorm';
@Entity('document_number_audit')
@Index(['generatedAt'])
@Index(['userId'])
export class DocumentNumberAudit {
@PrimaryGeneratedColumn()
id!: number;
@Column({ name: 'generated_number', length: 100 })
generatedNumber!: string;
@Column({ name: 'counter_key', length: 255 })
counterKey!: string;
@Column({ name: 'template_used', type: 'text' })
templateUsed!: string;
@Column({ name: 'sequence_number' })
sequenceNumber!: number;
@Column({ name: 'user_id', nullable: true })
userId?: number;
@Column({ name: 'ip_address', length: 45, nullable: true })
ipAddress?: string;
@Column({ name: 'retry_count', default: 0 })
retryCount!: number;
@Column({ name: 'lock_wait_ms', nullable: true })
lockWaitMs?: number;
@CreateDateColumn({ name: 'generated_at' })
generatedAt!: Date;
}

View File

@@ -0,0 +1,39 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
} from 'typeorm';
@Entity('document_number_errors')
@Index(['errorAt'])
@Index(['userId'])
export class DocumentNumberError {
@PrimaryGeneratedColumn()
id!: number;
@Column({ name: 'counter_key', length: 255 })
counterKey!: string;
@Column({ name: 'error_type', length: 50 })
errorType!: string;
@Column({ name: 'error_message', type: 'text' })
errorMessage!: string;
@Column({ name: 'stack_trace', type: 'text', nullable: true })
stackTrace?: string;
@Column({ name: 'user_id', nullable: true })
userId?: number;
@Column({ name: 'ip_address', length: 45, nullable: true })
ipAddress?: string;
@Column({ name: 'context', type: 'json', nullable: true })
context?: any;
@CreateDateColumn({ name: 'error_at' })
errorAt!: Date;
}

View File

@@ -8,6 +8,13 @@ export interface GenerateNumberContext {
disciplineId?: number; // (Optional) Discipline ID (สาขางาน)
year?: number; // (Optional) ถ้าไม่ส่งจะใช้ปีปัจจุบัน
// [P1-4] Recipient organization for {RECIPIENT} token
recipientOrgId?: number; // Primary recipient organization
// [P0-4] Audit tracking fields
userId?: number; // User requesting the number
ipAddress?: string; // IP address of the requester
// สำหรับกรณีพิเศษที่ต้องการ Override ค่าบางอย่าง
customTokens?: Record<string, string>;
}
@@ -21,4 +28,5 @@ export interface DecodedTokens {
subTypeNumber: string;
year: string;
yearShort: string;
recipientCode: string; // [P1-4] Recipient organization code
}

View File

@@ -0,0 +1,192 @@
import { Test, TestingModule } from '@nestjs/testing';
import { Repository } from 'typeorm';
import { getRepositoryToken } from '@nestjs/typeorm';
import { WorkflowDslParser } from './parser.service';
import { WorkflowDefinition } from '../entities/workflow-definition.entity';
import { RFA_WORKFLOW_EXAMPLE } from './workflow-dsl.schema';
import { BadRequestException } from '@nestjs/common';
describe('WorkflowDslParser', () => {
let parser: WorkflowDslParser;
let mockRepository: Partial<Repository<WorkflowDefinition>>;
beforeEach(async () => {
mockRepository = {
save: jest.fn((def) => Promise.resolve(def)),
findOne: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
WorkflowDslParser,
{
provide: getRepositoryToken(WorkflowDefinition),
useValue: mockRepository,
},
],
}).compile();
parser = module.get<WorkflowDslParser>(WorkflowDslParser);
});
it('should be defined', () => {
expect(parser).toBeDefined();
});
describe('parse', () => {
it('should parse valid RFA workflow DSL', async () => {
const dslJson = JSON.stringify(RFA_WORKFLOW_EXAMPLE);
const result = await parser.parse(dslJson);
expect(result).toBeDefined();
expect(result.name).toBe('RFA_APPROVAL');
expect(result.version).toBe('1.0.0');
expect(result.isActive).toBe(true);
expect(mockRepository.save).toHaveBeenCalled();
});
it('should reject invalid JSON', async () => {
const invalidJson = '{ invalid json }';
await expect(parser.parse(invalidJson)).rejects.toThrow(
BadRequestException
);
});
it('should reject workflow with invalid state reference', async () => {
const invalidDsl = {
name: 'INVALID',
version: '1.0.0',
states: ['DRAFT', 'APPROVED'],
initialState: 'DRAFT',
finalStates: ['APPROVED'],
transitions: [
{
from: 'DRAFT',
to: 'NONEXISTENT_STATE', // Invalid state
trigger: 'SUBMIT',
},
],
};
await expect(parser.parse(JSON.stringify(invalidDsl))).rejects.toThrow(
BadRequestException
);
});
it('should reject workflow with invalid initial state', async () => {
const invalidDsl = {
name: 'INVALID',
version: '1.0.0',
states: ['DRAFT', 'APPROVED'],
initialState: 'NONEXISTENT', // Invalid
finalStates: ['APPROVED'],
transitions: [],
};
await expect(parser.parse(JSON.stringify(invalidDsl))).rejects.toThrow(
BadRequestException
);
});
it('should reject workflow with invalid final state', async () => {
const invalidDsl = {
name: 'INVALID',
version: '1.0.0',
states: ['DRAFT', 'APPROVED'],
initialState: 'DRAFT',
finalStates: ['NONEXISTENT'], // Invalid
transitions: [],
};
await expect(parser.parse(JSON.stringify(invalidDsl))).rejects.toThrow(
BadRequestException
);
});
it('should reject workflow with duplicate transitions', async () => {
const invalidDsl = {
name: 'INVALID',
version: '1.0.0',
states: ['DRAFT', 'SUBMITTED'],
initialState: 'DRAFT',
finalStates: ['SUBMITTED'],
transitions: [
{ from: 'DRAFT', to: 'SUBMITTED', trigger: 'SUBMIT' },
{ from: 'DRAFT', to: 'SUBMITTED', trigger: 'SUBMIT' }, // Duplicate
],
};
await expect(parser.parse(JSON.stringify(invalidDsl))).rejects.toThrow(
BadRequestException
);
});
it('should reject workflow with invalid version format', async () => {
const invalidDsl = {
...RFA_WORKFLOW_EXAMPLE,
version: 'invalid-version',
};
await expect(parser.parse(JSON.stringify(invalidDsl))).rejects.toThrow(
BadRequestException
);
});
});
describe('validateOnly', () => {
it('should validate correct DSL without saving', () => {
const dslJson = JSON.stringify(RFA_WORKFLOW_EXAMPLE);
const result = parser.validateOnly(dslJson);
expect(result.valid).toBe(true);
expect(result.errors).toBeUndefined();
expect(mockRepository.save).not.toHaveBeenCalled();
});
it('should return error for invalid DSL', () => {
const invalidDsl = {
name: 'INVALID',
version: '1.0.0',
states: ['DRAFT'],
initialState: 'NONEXISTENT',
finalStates: [],
transitions: [],
};
const result = parser.validateOnly(JSON.stringify(invalidDsl));
expect(result.valid).toBe(false);
expect(result.errors).toBeDefined();
expect(result.errors.length).toBeGreaterThan(0);
});
});
describe('getParsedDsl', () => {
it('should retrieve and parse stored DSL', async () => {
const storedDefinition = {
id: 1,
name: 'RFA_APPROVAL',
version: '1.0.0',
dslContent: JSON.stringify(RFA_WORKFLOW_EXAMPLE),
};
mockRepository.findOne = jest.fn().mockResolvedValue(storedDefinition);
const result = await parser.getParsedDsl(1);
expect(result).toBeDefined();
expect(result.name).toBe('RFA_APPROVAL');
});
it('should throw error if definition not found', async () => {
mockRepository.findOne = jest.fn().mockResolvedValue(null);
await expect(parser.getParsedDsl(999)).rejects.toThrow(
BadRequestException
);
});
});
});

View File

@@ -0,0 +1,186 @@
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { WorkflowDslSchema, WorkflowDsl } from './workflow-dsl.schema';
import { WorkflowDefinition } from '../entities/workflow-definition.entity';
@Injectable()
export class WorkflowDslParser {
private readonly logger = new Logger(WorkflowDslParser.name);
constructor(
@InjectRepository(WorkflowDefinition)
private workflowDefRepo: Repository<WorkflowDefinition>
) {}
/**
* Parse และ Validate Workflow DSL from JSON string
* @param dslJson JSON string ของ Workflow DSL
* @returns WorkflowDefinition entity พร้อมบันทึกลง database
*/
async parse(dslJson: string): Promise<WorkflowDefinition> {
try {
// Step 1: Parse JSON
const rawDsl = JSON.parse(dslJson);
// Step 2: Validate with Zod schema
const dsl = WorkflowDslSchema.parse(rawDsl);
// Step 3: Validate state machine integrity
this.validateStateMachine(dsl);
// Step 4: Build WorkflowDefinition entity
const definition = this.buildDefinition(dsl);
// Step 5: Save to database
return await this.workflowDefRepo.save(definition);
} catch (error) {
if (error instanceof SyntaxError) {
throw new BadRequestException(`Invalid JSON: ${error.message}`);
}
if (error.name === 'ZodError') {
throw new BadRequestException(
`Invalid workflow DSL: ${JSON.stringify(error.errors)}`
);
}
throw error;
}
}
/**
* Validate state machine integrity
* ตรวจสอบว่า state machine ถูกต้องตามหลักการ:
* - All states in transitions must exist in states array
* - Initial state must exist
* - All final states must exist
* - No dead-end states (states with no outgoing transitions except final states)
*/
private validateStateMachine(dsl: WorkflowDsl): void {
const stateSet = new Set(dsl.states);
const finalStateSet = new Set(dsl.finalStates);
// 1. Validate initial state
if (!stateSet.has(dsl.initialState)) {
throw new BadRequestException(
`Initial state "${dsl.initialState}" not found in states array`
);
}
// 2. Validate final states
dsl.finalStates.forEach((state) => {
if (!stateSet.has(state)) {
throw new BadRequestException(
`Final state "${state}" not found in states array`
);
}
});
// 3. Validate transitions
const statesWithOutgoing = new Set<string>();
dsl.transitions.forEach((transition, index) => {
// Check 'from' state
if (!stateSet.has(transition.from)) {
throw new BadRequestException(
`Transition ${index}: 'from' state "${transition.from}" not found in states array`
);
}
// Check 'to' state
if (!stateSet.has(transition.to)) {
throw new BadRequestException(
`Transition ${index}: 'to' state "${transition.to}" not found in states array`
);
}
// Track states with outgoing transitions
statesWithOutgoing.add(transition.from);
});
// 4. Check for dead-end states (except final states)
const nonFinalStates = dsl.states.filter(
(state) => !finalStateSet.has(state)
);
nonFinalStates.forEach((state) => {
if (!statesWithOutgoing.has(state) && state !== dsl.initialState) {
this.logger.warn(
`Warning: State "${state}" has no outgoing transitions (potential dead-end)`
);
}
});
// 5. Check for duplicate transitions
const transitionKeys = new Set<string>();
dsl.transitions.forEach((transition) => {
const key = `${transition.from}-${transition.trigger}-${transition.to}`;
if (transitionKeys.has(key)) {
throw new BadRequestException(
`Duplicate transition: ${transition.from} --[${transition.trigger}]--> ${transition.to}`
);
}
transitionKeys.add(key);
});
this.logger.log(
`Workflow "${dsl.name}" v${dsl.version} validated successfully`
);
}
/**
* Build WorkflowDefinition entity from validated DSL
*/
private buildDefinition(dsl: WorkflowDsl): WorkflowDefinition {
const definition = new WorkflowDefinition();
definition.name = dsl.name;
definition.version = dsl.version;
definition.description = dsl.description;
definition.dslContent = JSON.stringify(dsl, null, 2); // Pretty print for readability
definition.isActive = true;
return definition;
}
/**
* Get parsed DSL from WorkflowDefinition
*/
async getParsedDsl(definitionId: number): Promise<WorkflowDsl> {
const definition = await this.workflowDefRepo.findOne({
where: { id: definitionId },
});
if (!definition) {
throw new BadRequestException(
`Workflow definition ${definitionId} not found`
);
}
try {
const dsl = JSON.parse(definition.dslContent);
return WorkflowDslSchema.parse(dsl);
} catch (error) {
this.logger.error(
`Failed to parse stored DSL for definition ${definitionId}`,
error
);
throw new BadRequestException(`Invalid stored DSL: ${error.message}`);
}
}
/**
* Validate DSL without saving (dry-run)
*/
validateOnly(dslJson: string): { valid: boolean; errors?: string[] } {
try {
const rawDsl = JSON.parse(dslJson);
const dsl = WorkflowDslSchema.parse(rawDsl);
this.validateStateMachine(dsl);
return { valid: true };
} catch (error) {
return {
valid: false,
errors: [error.message],
};
}
}
}

View File

@@ -0,0 +1,191 @@
import { z } from 'zod';
/**
* Zod Schema สำหรับ Workflow DSL
* ตาม ADR-001: Unified Workflow Engine
*/
// Guard Schema
export const GuardSchema = z.object({
type: z.enum(['permission', 'condition', 'script']),
config: z.record(z.string(), z.any()),
});
export type WorkflowGuard = z.infer<typeof GuardSchema>;
// Effect Schema
export const EffectSchema = z.object({
type: z.enum([
'update_status',
'send_email',
'send_line',
'create_notification',
'assign_user',
'update_field',
]),
config: z.record(z.string(), z.any()),
});
export type WorkflowEffect = z.infer<typeof EffectSchema>;
// Transition Schema
export const TransitionSchema = z.object({
from: z.string(),
to: z.string(),
trigger: z.string(),
label: z.string().optional(),
guards: z.array(GuardSchema).optional(),
effects: z.array(EffectSchema).optional(),
});
export type WorkflowTransition = z.infer<typeof TransitionSchema>;
// Main Workflow DSL Schema
export const WorkflowDslSchema = z.object({
name: z.string().min(1, 'Workflow name is required'),
version: z
.string()
.regex(/^\d+\.\d+(\.\d+)?$/, 'Version must be semver format (e.g., 1.0.0)'),
description: z.string().optional(),
states: z.array(z.string()).min(1, 'At least one state is required'),
initialState: z.string(),
finalStates: z
.array(z.string())
.min(1, 'At least one final state is required'),
transitions: z
.array(TransitionSchema)
.min(1, 'At least one transition is required'),
metadata: z.record(z.string(), z.any()).optional(),
});
export type WorkflowDsl = z.infer<typeof WorkflowDslSchema>;
/**
* ตัวอย่าง RFA Workflow DSL
*/
export const RFA_WORKFLOW_EXAMPLE: WorkflowDsl = {
name: 'RFA_APPROVAL',
version: '1.0.0',
description: 'Request for Approval workflow for construction projects',
states: [
'DRAFT',
'SUBMITTED',
'UNDER_REVIEW',
'APPROVED',
'REJECTED',
'REVISED',
],
initialState: 'DRAFT',
finalStates: ['APPROVED', 'REJECTED'],
transitions: [
{
from: 'DRAFT',
to: 'SUBMITTED',
trigger: 'SUBMIT',
label: 'Submit for approval',
guards: [
{
type: 'permission',
config: { permission: 'rfa.submit' },
},
],
effects: [
{
type: 'update_status',
config: { status: 'SUBMITTED' },
},
{
type: 'send_email',
config: {
to: 'reviewer@example.com',
template: 'rfa_submitted',
},
},
],
},
{
from: 'SUBMITTED',
to: 'UNDER_REVIEW',
trigger: 'START_REVIEW',
label: 'Start review',
guards: [
{
type: 'permission',
config: { permission: 'rfa.review' },
},
],
},
{
from: 'UNDER_REVIEW',
to: 'APPROVED',
trigger: 'APPROVE',
label: 'Approve',
guards: [
{
type: 'permission',
config: { permission: 'rfa.approve' },
},
],
effects: [
{
type: 'update_status',
config: { status: 'APPROVED' },
},
{
type: 'send_notification',
config: {
message: 'RFA has been approved',
type: 'success',
},
},
],
},
{
from: 'UNDER_REVIEW',
to: 'REJECTED',
trigger: 'REJECT',
label: 'Reject',
guards: [
{
type: 'permission',
config: { permission: 'rfa.approve' },
},
],
effects: [
{
type: 'update_status',
config: { status: 'REJECTED' },
},
],
},
{
from: 'UNDER_REVIEW',
to: 'REVISED',
trigger: 'REQUEST_REVISION',
label: 'Request revision',
effects: [
{
type: 'create_notification',
config: {
message: 'Please revise and resubmit',
},
},
],
},
{
from: 'REVISED',
to: 'SUBMITTED',
trigger: 'RESUBMIT',
label: 'Resubmit after revision',
},
],
metadata: {
documentType: 'RFA',
estimatedDuration: '5 days',
},
};