251206:1400 version 1.5.1
This commit is contained in:
@@ -1,20 +1,23 @@
|
||||
// File: src/common/auth/auth.module.ts
|
||||
// บันทึกการแก้ไข: แก้ไข Type Mismatch ของ expiresIn (Fix TS2322)
|
||||
// [P0-1] เพิ่ม CASL RBAC Integration
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm'; // [NEW] 1. Import TypeOrmModule
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AuthService } from './auth.service.js';
|
||||
import { AuthController } from './auth.controller.js';
|
||||
import { UserModule } from '../../modules/user/user.module.js';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy.js';
|
||||
import { JwtRefreshStrategy } from './strategies/jwt-refresh.strategy.js';
|
||||
import { User } from '../../modules/user/entities/user.entity'; // [NEW] 2. Import User Entity
|
||||
import { User } from '../../modules/user/entities/user.entity';
|
||||
import { CaslModule } from './casl/casl.module'; // [P0-1] Import CASL
|
||||
import { PermissionsGuard } from './guards/permissions.guard'; // [P0-1] Import Guard
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
// [NEW] 3. Register User Entity เพื่อให้ AuthService ใช้ InjectRepository(User) ได้
|
||||
TypeOrmModule.forFeature([User]),
|
||||
UserModule,
|
||||
PassportModule,
|
||||
@@ -24,15 +27,23 @@ import { User } from '../../modules/user/entities/user.entity'; // [NEW] 2. Impo
|
||||
useFactory: async (configService: ConfigService) => ({
|
||||
secret: configService.get<string>('JWT_SECRET'),
|
||||
signOptions: {
|
||||
// ✅ Fix: Cast เป็น any เพื่อแก้ปัญหา Type ไม่ตรงกับ Library (StringValue vs string)
|
||||
expiresIn: (configService.get<string>('JWT_EXPIRATION') ||
|
||||
'15m') as any,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
CaslModule, // [P0-1] Import CASL module
|
||||
],
|
||||
providers: [
|
||||
AuthService,
|
||||
JwtStrategy,
|
||||
JwtRefreshStrategy,
|
||||
PermissionsGuard, // [P0-1] Register PermissionsGuard
|
||||
],
|
||||
providers: [AuthService, JwtStrategy, JwtRefreshStrategy],
|
||||
controllers: [AuthController],
|
||||
exports: [AuthService],
|
||||
exports: [
|
||||
AuthService,
|
||||
PermissionsGuard, // [P0-1] Export for use in other modules
|
||||
],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
131
backend/src/common/auth/casl/README.md
Normal file
131
backend/src/common/auth/casl/README.md
Normal file
@@ -0,0 +1,131 @@
|
||||
# P0-1: CASL RBAC Integration - Usage Example
|
||||
|
||||
## ตัวอย่างการใช้งานใน Controller
|
||||
|
||||
### 1. Import Required Dependencies
|
||||
|
||||
```typescript
|
||||
import { Controller, Post, Get, UseGuards, Body, Param } from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../common/auth/guards/jwt-auth.guard';
|
||||
import { PermissionsGuard } from '../common/auth/guards/permissions.guard';
|
||||
import { RequirePermission } from '../common/decorators/require-permission.decorator';
|
||||
```
|
||||
|
||||
### 2. Apply Guards and Permissions
|
||||
|
||||
```typescript
|
||||
@Controller('correspondences')
|
||||
@UseGuards(JwtAuthGuard) // Step 1: Authenticate user
|
||||
export class CorrespondenceController {
|
||||
|
||||
// ตัวอย่าง 1: Single Permission
|
||||
@Post()
|
||||
@UseGuards(PermissionsGuard) // Step 2: Check permissions
|
||||
@RequirePermission('correspondence.create')
|
||||
async create(@Body() dto: CreateCorrespondenceDto) {
|
||||
// Only users with 'correspondence.create' permission can access
|
||||
return this.correspondenceService.create(dto);
|
||||
}
|
||||
|
||||
// ตัวอย่าง 2: View (typically everyone with access)
|
||||
@Get(':id')
|
||||
@UseGuards(PermissionsGuard)
|
||||
@RequirePermission('correspondence.view')
|
||||
async findOne(@Param('id') id: string) {
|
||||
return this.correspondenceService.findOne(+id);
|
||||
}
|
||||
|
||||
// ตัวอย่าง 3: Admin Edit (requires special permission)
|
||||
@Put(':id/force-update')
|
||||
@UseGuards(PermissionsGuard)
|
||||
@RequirePermission('document.admin_edit')
|
||||
async forceUpdate(@Param('id') id: string, @Body() dto: UpdateDto) {
|
||||
// Only document controllers can force update
|
||||
return this.correspondenceService.forceUpdate(+id, dto);
|
||||
}
|
||||
|
||||
// ตัวอย่าง 4: Multiple Permissions (user must have ALL)
|
||||
@Delete(':id')
|
||||
@UseGuards(PermissionsGuard)
|
||||
@RequirePermission('correspondence.delete', 'document.admin_edit')
|
||||
async remove(@Param('id') id: string) {
|
||||
// Requires BOTH permissions
|
||||
return this.correspondenceService.remove(+id);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Controller with Scope Context
|
||||
|
||||
Permissions guard จะ extract scope จาก request params/body/query:
|
||||
|
||||
```typescript
|
||||
@Controller('projects/:projectId/correspondences')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ProjectCorrespondenceController {
|
||||
|
||||
@Post()
|
||||
@UseGuards(PermissionsGuard)
|
||||
@RequirePermission('correspondence.create')
|
||||
async create(
|
||||
@Param('projectId') projectId: string,
|
||||
@Body() dto: CreateCorrespondenceDto
|
||||
) {
|
||||
// PermissionsGuard จะ extract: { projectId: projectId }
|
||||
// และตรวจสอบว่า user มี permission ใน project นี้หรือไม่
|
||||
return this.service.create({ projectId, ...dto });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## หลักการทำงาน
|
||||
|
||||
### Scope Matching Hierarchy
|
||||
|
||||
1. **Global Scope**: User ที่มี assignment โดยไม่ระบุ org/project/contract
|
||||
- สามารถ access ทุกอย่างได้
|
||||
|
||||
2. **Organization Scope**: User ที่มี assignment ระดับ organization
|
||||
- สามารถ access resources ใน organization นั้นเท่านั้น
|
||||
|
||||
3. **Project Scope**: User ที่มี assignment ระดับ project
|
||||
- สามารถ access resources ใน project นั้นเท่านั้น
|
||||
|
||||
4. **Contract Scope**: User ที่มี assignment ระดับ contract
|
||||
- สามารถ access resources ใน contract นั้นเท่านั้น
|
||||
|
||||
### Permission Format
|
||||
|
||||
Permission ใน database ต้องเป็นรูปแบบ: `{subject}.{action}`
|
||||
|
||||
ตัวอย่าง:
|
||||
- `correspondence.create`
|
||||
- `correspondence.view`
|
||||
- `correspondence.edit`
|
||||
- `document.admin_edit`
|
||||
- `rfa.create`
|
||||
- `project.manage_members`
|
||||
- `system.manage_all` (special case)
|
||||
|
||||
## Testing
|
||||
|
||||
Run unit tests:
|
||||
```bash
|
||||
npm run test -- ability.factory.spec
|
||||
```
|
||||
|
||||
Expected output:
|
||||
```
|
||||
✓ should grant all permissions for global admin
|
||||
✓ should grant permissions for matching organization
|
||||
✓ should deny permissions for non-matching organization
|
||||
✓ should grant permissions for matching project
|
||||
✓ should grant permissions for matching contract
|
||||
✓ should combine permissions from multiple assignments
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Update existing controllers to use `@RequirePermission()`
|
||||
2. Test with different user roles
|
||||
3. Verify scope matching works correctly
|
||||
164
backend/src/common/auth/casl/ability.factory.spec.ts
Normal file
164
backend/src/common/auth/casl/ability.factory.spec.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AbilityFactory, ScopeContext } from './ability.factory';
|
||||
import { User } from '../../../modules/user/entities/user.entity';
|
||||
import { UserAssignment } from '../../../modules/user/entities/user-assignment.entity';
|
||||
|
||||
describe('AbilityFactory', () => {
|
||||
let factory: AbilityFactory;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [AbilityFactory],
|
||||
}).compile();
|
||||
|
||||
factory = module.get<AbilityFactory>(AbilityFactory);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(factory).toBeDefined();
|
||||
});
|
||||
|
||||
describe('Global Admin', () => {
|
||||
it('should grant all permissions for global admin', () => {
|
||||
const user = createMockUser({
|
||||
assignments: [
|
||||
createMockAssignment({
|
||||
organizationId: undefined,
|
||||
projectId: undefined,
|
||||
contractId: undefined,
|
||||
permissionNames: ['system.manage_all'],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const ability = factory.createForUser(user, {});
|
||||
|
||||
expect(ability.can('manage', 'all')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Organization Level', () => {
|
||||
it('should grant permissions for matching organization', () => {
|
||||
const user = createMockUser({
|
||||
assignments: [
|
||||
createMockAssignment({
|
||||
organizationId: 1,
|
||||
permissionNames: ['correspondence.create', 'correspondence.read'],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const context: ScopeContext = { organizationId: 1 };
|
||||
const ability = factory.createForUser(user, context);
|
||||
|
||||
expect(ability.can('create', 'correspondence')).toBe(true);
|
||||
expect(ability.can('read', 'correspondence')).toBe(true);
|
||||
});
|
||||
|
||||
it('should deny permissions for non-matching organization', () => {
|
||||
const user = createMockUser({
|
||||
assignments: [
|
||||
createMockAssignment({
|
||||
organizationId: 1,
|
||||
permissionNames: ['correspondence.create'],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const context: ScopeContext = { organizationId: 2 };
|
||||
const ability = factory.createForUser(user, context);
|
||||
|
||||
expect(ability.can('create', 'correspondence')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Project Level', () => {
|
||||
it('should grant permissions for matching project', () => {
|
||||
const user = createMockUser({
|
||||
assignments: [
|
||||
createMockAssignment({
|
||||
projectId: 10,
|
||||
permissionNames: ['rfa.create'],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const context: ScopeContext = { projectId: 10 };
|
||||
const ability = factory.createForUser(user, context);
|
||||
|
||||
expect(ability.can('create', 'rfa')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Contract Level', () => {
|
||||
it('should grant permissions for matching contract', () => {
|
||||
const user = createMockUser({
|
||||
assignments: [
|
||||
createMockAssignment({
|
||||
contractId: 5,
|
||||
permissionNames: ['drawing.create'],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const context: ScopeContext = { contractId: 5 };
|
||||
const ability = factory.createForUser(user, context);
|
||||
|
||||
expect(ability.can('create', 'drawing')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multiple Assignments', () => {
|
||||
it('should combine permissions from multiple assignments', () => {
|
||||
const user = createMockUser({
|
||||
assignments: [
|
||||
createMockAssignment({
|
||||
organizationId: 1,
|
||||
permissionNames: ['correspondence.create'],
|
||||
}),
|
||||
createMockAssignment({
|
||||
projectId: 10,
|
||||
permissionNames: ['rfa.create'],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const orgAbility = factory.createForUser(user, { organizationId: 1 });
|
||||
expect(orgAbility.can('create', 'correspondence')).toBe(true);
|
||||
|
||||
const projectAbility = factory.createForUser(user, { projectId: 10 });
|
||||
expect(projectAbility.can('create', 'rfa')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Helper functions using mock objects
|
||||
function createMockUser(props: { assignments: UserAssignment[] }): User {
|
||||
const user = new User();
|
||||
user.user_id = 1;
|
||||
user.username = 'testuser';
|
||||
user.email = 'test@example.com';
|
||||
user.assignments = props.assignments;
|
||||
return user;
|
||||
}
|
||||
|
||||
function createMockAssignment(props: {
|
||||
organizationId?: number;
|
||||
projectId?: number;
|
||||
contractId?: number;
|
||||
permissionNames: string[];
|
||||
}): UserAssignment {
|
||||
const assignment = new UserAssignment();
|
||||
assignment.organizationId = props.organizationId;
|
||||
assignment.projectId = props.projectId;
|
||||
assignment.contractId = props.contractId;
|
||||
|
||||
// Create mock role with permissions
|
||||
assignment.role = {
|
||||
permissions: props.permissionNames.map((name) => ({
|
||||
permissionName: name,
|
||||
})),
|
||||
} as any;
|
||||
|
||||
return assignment;
|
||||
}
|
||||
136
backend/src/common/auth/casl/ability.factory.ts
Normal file
136
backend/src/common/auth/casl/ability.factory.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
Ability,
|
||||
AbilityBuilder,
|
||||
AbilityClass,
|
||||
ExtractSubjectType,
|
||||
InferSubjects,
|
||||
} from '@casl/ability';
|
||||
import { User } from '../../../modules/user/entities/user.entity';
|
||||
import { UserAssignment } from '../../../modules/user/entities/user-assignment.entity';
|
||||
|
||||
// Define action types
|
||||
type Actions = 'create' | 'read' | 'update' | 'delete' | 'manage';
|
||||
|
||||
// Define subject types (resources)
|
||||
type Subjects =
|
||||
| 'correspondence'
|
||||
| 'rfa'
|
||||
| 'drawing'
|
||||
| 'transmittal'
|
||||
| 'circulation'
|
||||
| 'project'
|
||||
| 'organization'
|
||||
| 'user'
|
||||
| 'role'
|
||||
| 'workflow'
|
||||
| 'all';
|
||||
|
||||
export type AppAbility = Ability<[Actions, Subjects]>;
|
||||
|
||||
export interface ScopeContext {
|
||||
organizationId?: number;
|
||||
projectId?: number;
|
||||
contractId?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AbilityFactory {
|
||||
/**
|
||||
* สร้าง Ability object สำหรับ User ในบริบทที่กำหนด
|
||||
* รองรับ 4-Level Hierarchical RBAC:
|
||||
* - Level 1: Global (no scope)
|
||||
* - Level 2: Organization
|
||||
* - Level 3: Project
|
||||
* - Level 4: Contract
|
||||
*/
|
||||
createForUser(user: User, context: ScopeContext): AppAbility {
|
||||
const { can, cannot, build } = new AbilityBuilder<AppAbility>(
|
||||
Ability as AbilityClass<AppAbility>
|
||||
);
|
||||
|
||||
if (!user || !user.assignments) {
|
||||
// No permissions for unauthenticated or incomplete user
|
||||
return build();
|
||||
}
|
||||
|
||||
// Iterate through user's role assignments
|
||||
user.assignments.forEach((assignment: UserAssignment) => {
|
||||
// Check if assignment matches the current context
|
||||
if (this.matchesScope(assignment, context)) {
|
||||
// Grant permissions from the role
|
||||
assignment.role.permissions.forEach((permission) => {
|
||||
const [action, subject] = this.parsePermission(
|
||||
permission.permissionName
|
||||
);
|
||||
can(action as Actions, subject as Subjects);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return build({
|
||||
// Detect subject type (for future use with objects)
|
||||
detectSubjectType: (item) =>
|
||||
item.constructor as ExtractSubjectType<Subjects>,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* ตรวจสอบว่า Assignment ตรงกับ Scope Context หรือไม่
|
||||
* Hierarchical matching:
|
||||
* - Global assignment matches all contexts
|
||||
* - Organization assignment matches if org IDs match
|
||||
* - Project assignment matches if project IDs match
|
||||
* - Contract assignment matches if contract IDs match
|
||||
*/
|
||||
private matchesScope(
|
||||
assignment: UserAssignment,
|
||||
context: ScopeContext
|
||||
): boolean {
|
||||
// Level 1: Global scope (no organizationId, projectId, contractId)
|
||||
if (
|
||||
!assignment.organizationId &&
|
||||
!assignment.projectId &&
|
||||
!assignment.contractId
|
||||
) {
|
||||
return true; // Global admin can access everything
|
||||
}
|
||||
|
||||
// Level 4: Contract scope (most specific)
|
||||
if (assignment.contractId) {
|
||||
return context.contractId === assignment.contractId;
|
||||
}
|
||||
|
||||
// Level 3: Project scope
|
||||
if (assignment.projectId) {
|
||||
return context.projectId === assignment.projectId;
|
||||
}
|
||||
|
||||
// Level 2: Organization scope
|
||||
if (assignment.organizationId) {
|
||||
return context.organizationId === assignment.organizationId;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* แปลง permission name เป็น [action, subject]
|
||||
* Format: "correspondence.create" → ["create", "correspondence"]
|
||||
* "project.view" → ["view", "project"]
|
||||
*/
|
||||
private parsePermission(permissionName: string): [string, string] {
|
||||
const parts = permissionName.split('.');
|
||||
if (parts.length === 2) {
|
||||
const [subject, action] = parts;
|
||||
return [action, subject];
|
||||
}
|
||||
|
||||
// Fallback for special permissions like "system.manage_all"
|
||||
if (permissionName === 'system.manage_all') {
|
||||
return ['manage', 'all'];
|
||||
}
|
||||
|
||||
throw new Error(`Invalid permission format: ${permissionName}`);
|
||||
}
|
||||
}
|
||||
8
backend/src/common/auth/casl/casl.module.ts
Normal file
8
backend/src/common/auth/casl/casl.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AbilityFactory } from './ability.factory';
|
||||
|
||||
@Module({
|
||||
providers: [AbilityFactory],
|
||||
exports: [AbilityFactory],
|
||||
})
|
||||
export class CaslModule {}
|
||||
100
backend/src/common/auth/guards/permissions.guard.ts
Normal file
100
backend/src/common/auth/guards/permissions.guard.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { AbilityFactory, ScopeContext } from '../casl/ability.factory';
|
||||
import { PERMISSIONS_KEY } from '../../decorators/require-permission.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class PermissionsGuard implements CanActivate {
|
||||
constructor(
|
||||
private reflector: Reflector,
|
||||
private abilityFactory: AbilityFactory
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
// Get required permissions from decorator metadata
|
||||
const requiredPermissions = this.reflector.getAllAndOverride<string[]>(
|
||||
PERMISSIONS_KEY,
|
||||
[context.getHandler(), context.getClass()]
|
||||
);
|
||||
|
||||
// If no permissions required, allow access
|
||||
if (!requiredPermissions || requiredPermissions.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const user = request.user;
|
||||
|
||||
if (!user) {
|
||||
throw new ForbiddenException('User not authenticated');
|
||||
}
|
||||
|
||||
// Extract scope context from request
|
||||
const scopeContext = this.extractScope(request);
|
||||
|
||||
// Create ability for user in this context
|
||||
const ability = this.abilityFactory.createForUser(user, scopeContext);
|
||||
|
||||
// Check if user has ALL required permissions
|
||||
const hasPermission = requiredPermissions.every((permission) => {
|
||||
const [action, subject] = this.parsePermission(permission);
|
||||
return ability.can(action, subject);
|
||||
});
|
||||
|
||||
if (!hasPermission) {
|
||||
throw new ForbiddenException(
|
||||
`User does not have required permissions: ${requiredPermissions.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract scope context from request
|
||||
* Priority: params > body > query
|
||||
*/
|
||||
private extractScope(request: any): ScopeContext {
|
||||
return {
|
||||
organizationId:
|
||||
request.params.organizationId ||
|
||||
request.body.organizationId ||
|
||||
request.query.organizationId ||
|
||||
undefined,
|
||||
projectId:
|
||||
request.params.projectId ||
|
||||
request.body.projectId ||
|
||||
request.query.projectId ||
|
||||
undefined,
|
||||
contractId:
|
||||
request.params.contractId ||
|
||||
request.body.contractId ||
|
||||
request.query.contractId ||
|
||||
undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse permission string to [action, subject]
|
||||
* Example: "correspondence.create" → ["create", "correspondence"]
|
||||
*/
|
||||
private parsePermission(permission: string): [string, string] {
|
||||
const parts = permission.split('.');
|
||||
if (parts.length === 2) {
|
||||
const [subject, action] = parts;
|
||||
return [action, subject];
|
||||
}
|
||||
|
||||
// Handle special case: system.manage_all
|
||||
if (permission === 'system.manage_all') {
|
||||
return ['manage', 'all'];
|
||||
}
|
||||
|
||||
throw new Error(`Invalid permission format: ${permission}`);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const PERMISSION_KEY = 'permissions';
|
||||
export const PERMISSIONS_KEY = 'permissions'; // Changed from PERMISSION_KEY
|
||||
|
||||
// ใช้สำหรับแปะหน้า Controller/Method
|
||||
// ตัวอย่าง: @RequirePermission('user.create')
|
||||
export const RequirePermission = (permission: string) =>
|
||||
SetMetadata(PERMISSION_KEY, permission);
|
||||
/**
|
||||
* Decorator สำหรับกำหนด permissions ที่จำเป็นสำหรับ route
|
||||
* รองรับ multiple permissions (user ต้องมี ALL permissions)
|
||||
*/
|
||||
export const RequirePermission = (...permissions: string[]) =>
|
||||
SetMetadata(PERMISSIONS_KEY, permissions);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
192
backend/src/modules/workflow-engine/dsl/parser.service.spec.ts
Normal file
192
backend/src/modules/workflow-engine/dsl/parser.service.spec.ts
Normal 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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
186
backend/src/modules/workflow-engine/dsl/parser.service.ts
Normal file
186
backend/src/modules/workflow-engine/dsl/parser.service.ts
Normal 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],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
191
backend/src/modules/workflow-engine/dsl/workflow-dsl.schema.ts
Normal file
191
backend/src/modules/workflow-engine/dsl/workflow-dsl.schema.ts
Normal 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',
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user