690414:1113 Update README.md /.agents/skills, /.windsurf/workflows
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
// ADR-021: Response DTOs สำหรับ GET /instances/:id/history
|
||||
export class AttachmentSummaryDto {
|
||||
publicId!: string;
|
||||
originalFilename!: string;
|
||||
mimeType?: string;
|
||||
fileSize?: number;
|
||||
}
|
||||
|
||||
export class WorkflowHistoryItemDto {
|
||||
id!: string;
|
||||
fromState!: string;
|
||||
toState!: string;
|
||||
action!: string;
|
||||
actionByUserId?: number;
|
||||
comment?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
attachments!: AttachmentSummaryDto[];
|
||||
createdAt!: string;
|
||||
}
|
||||
@@ -1,7 +1,15 @@
|
||||
// File: src/modules/workflow-engine/dto/workflow-transition.dto.ts
|
||||
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsObject, IsOptional, IsString } from 'class-validator';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsNotEmpty,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
} from 'class-validator';
|
||||
|
||||
export class WorkflowTransitionDto {
|
||||
@ApiProperty({
|
||||
@@ -27,4 +35,16 @@ export class WorkflowTransitionDto {
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
payload?: Record<string, unknown>;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'รายการ publicId ของไฟล์แนบ (ต้องอัปโหลดผ่าน Two-Phase ก่อน — ADR-016)',
|
||||
example: ['019505a1-7c3e-7000-8000-abc123def456'],
|
||||
type: [String],
|
||||
})
|
||||
@IsArray()
|
||||
@IsUUID('all', { each: true })
|
||||
@ArrayMaxSize(20)
|
||||
@IsOptional()
|
||||
attachmentPublicIds?: string[];
|
||||
}
|
||||
|
||||
@@ -7,8 +7,10 @@ import {
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
import { Attachment } from '../../../common/file-storage/entities/attachment.entity';
|
||||
import { WorkflowInstance } from './workflow-instance.entity';
|
||||
|
||||
/**
|
||||
@@ -58,4 +60,12 @@ export class WorkflowHistory {
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
|
||||
// ADR-021: ไฟล์แนบที่อัปโหลดพร้อมขั้นตอนนี้ — Lazy โหลดเฉพาะเมื่อต้องการ (ป้องกัน N+1)
|
||||
@OneToMany(
|
||||
() => Attachment,
|
||||
(attachment: Attachment) => attachment.workflowHistory,
|
||||
{ lazy: true }
|
||||
)
|
||||
attachments?: Promise<Attachment[]>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// File: src/modules/workflow-engine/guards/workflow-transition.guard.ts
|
||||
// Guard ตรวจสอบสิทธิ์ 4-Level RBAC สำหรับ Workflow Transition ตาม ADR-021 §6
|
||||
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { WorkflowInstance } from '../entities/workflow-instance.entity';
|
||||
import { UserService } from '../../../modules/user/user.service';
|
||||
import type { RequestWithUser } from '../../../common/interfaces/request-with-user.interface';
|
||||
|
||||
/**
|
||||
* WorkflowTransitionGuard — ตรวจสอบสิทธิ์ 4 ระดับก่อนอนุญาตให้เปลี่ยนสถานะ Workflow
|
||||
*
|
||||
* Level 1: system.manage_all (Superadmin) → ผ่านทันที
|
||||
* Level 2: organization.manage_users + สังกัดองค์กรเดียวกับเอกสาร → ผ่าน
|
||||
* Level 3: Assigned Handler (context.assignedUserId === req.user.user_id) → ผ่าน
|
||||
* Level 4: ผู้ใช้ทั่วไป → ForbiddenException
|
||||
*/
|
||||
@Injectable()
|
||||
export class WorkflowTransitionGuard implements CanActivate {
|
||||
private readonly logger = new Logger(WorkflowTransitionGuard.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(WorkflowInstance)
|
||||
private readonly instanceRepo: Repository<WorkflowInstance>,
|
||||
private readonly userService: UserService
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<RequestWithUser>();
|
||||
const instanceId = request.params['id'];
|
||||
const user = request.user;
|
||||
|
||||
// ดึงสิทธิ์ทั้งหมดของ User จาก DB (ตาม pattern เดียวกับ RbacGuard)
|
||||
const userPermissions = await this.userService.getUserPermissions(
|
||||
user.user_id
|
||||
);
|
||||
|
||||
// Level 1: Superadmin — ผ่านทุกการตรวจสอบ
|
||||
if (userPermissions.includes('system.manage_all')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// ดึง Instance เพื่อตรวจสอบ Context
|
||||
const instance = await this.instanceRepo.findOne({
|
||||
where: { id: instanceId },
|
||||
});
|
||||
|
||||
if (!instance) {
|
||||
throw new NotFoundException('Workflow Instance', instanceId);
|
||||
}
|
||||
|
||||
// Level 2: Org Admin — organization.manage_users + สังกัดองค์กรเดียวกับเอกสาร
|
||||
const docOrgId = instance.context?.organizationId as number | undefined;
|
||||
if (
|
||||
userPermissions.includes('organization.manage_users') &&
|
||||
docOrgId !== undefined &&
|
||||
user.primaryOrganizationId === docOrgId
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Level 3: Assigned Handler — User นี้ถูก Assign มาให้ทำ Step นี้โดยตรง
|
||||
const assignedUserId = instance.context?.assignedUserId as
|
||||
| number
|
||||
| undefined;
|
||||
if (assignedUserId !== undefined && user.user_id === assignedUserId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
this.logger.warn(
|
||||
`Unauthorized transition attempt: User ${user.user_id} on Instance ${instanceId}`
|
||||
);
|
||||
throw new ForbiddenException({
|
||||
userMessage: 'คุณไม่มีสิทธิ์ดำเนินการในขั้นตอนนี้',
|
||||
recoveryAction: 'ติดต่อผู้รับผิดชอบหรือ Admin หากคิดว่านี่เป็นข้อผิดพลาด',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,20 @@
|
||||
// File: src/modules/workflow-engine/workflow-engine.controller.ts
|
||||
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Headers,
|
||||
Inject,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Request,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||
import type { Cache } from 'cache-manager';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
@@ -27,10 +32,11 @@ 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 ในแผนงาน)
|
||||
// Guards & Decorators
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { RbacGuard } from '../../common/guards/rbac.guard';
|
||||
import { WorkflowTransitionGuard } from './guards/workflow-transition.guard';
|
||||
import type { RequestWithUser } from '../../common/interfaces/request-with-user.interface';
|
||||
|
||||
@ApiTags('Workflow Engine')
|
||||
@@ -38,7 +44,10 @@ import type { RequestWithUser } from '../../common/interfaces/request-with-user.
|
||||
@Controller('workflow-engine')
|
||||
@UseGuards(JwtAuthGuard, RbacGuard) // บังคับ Login และตรวจสอบสิทธิ์ทุก Request
|
||||
export class WorkflowEngineController {
|
||||
constructor(private readonly workflowService: WorkflowEngineService) {}
|
||||
constructor(
|
||||
private readonly workflowService: WorkflowEngineService,
|
||||
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache
|
||||
) {}
|
||||
|
||||
// =================================================================
|
||||
// Definition Management (Admin / Developer)
|
||||
@@ -89,25 +98,56 @@ export class WorkflowEngineController {
|
||||
// =================================================================
|
||||
|
||||
@Post('instances/:id/transition')
|
||||
@ApiOperation({ summary: 'สั่งเปลี่ยนสถานะเอกสาร (User Action)' })
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'สั่งเปลี่ยนสถานะเอกสาร (User Action) — ADR-021: 4-Level RBAC + Idempotency',
|
||||
})
|
||||
@ApiParam({ name: 'id', description: 'Workflow Instance ID (UUID)' })
|
||||
// Permission จะถูกตรวจสอบ Dynamic ภายใน Service ตาม State ของ Workflow แต่ขั้นต้นต้องมีสิทธิ์ทำงาน Workflow
|
||||
@RequirePermission('workflow.action_review')
|
||||
// ADR-021: แทนที่ @RequirePermission สามัญใช้ WorkflowTransitionGuard (4-Level RBAC เต็มรูปแบบ)
|
||||
@UseGuards(WorkflowTransitionGuard)
|
||||
async processTransition(
|
||||
@Param('id') instanceId: string,
|
||||
@Body() dto: WorkflowTransitionDto,
|
||||
@Request() req: RequestWithUser
|
||||
@Request() req: RequestWithUser,
|
||||
@Headers('Idempotency-Key') idempotencyKey: string
|
||||
) {
|
||||
// ดึง User ID จาก Token (req.user มาจาก JwtStrategy)
|
||||
// ADR-016: Idempotency-Key ต้องมีทุก Request
|
||||
if (!idempotencyKey) {
|
||||
throw new BadRequestException('Idempotency-Key header is required');
|
||||
}
|
||||
|
||||
// ตรวจ Redis ว่า Request นี้ถูกส่งมาแล้วหรือไม่
|
||||
const cacheKey = `idempotency:wf:${idempotencyKey}`;
|
||||
const cached = await this.cacheManager.get(cacheKey);
|
||||
if (cached) {
|
||||
return cached; // คืนผลเดิม (Idempotent Response)
|
||||
}
|
||||
|
||||
const userId = req.user?.user_id;
|
||||
|
||||
return this.workflowService.processTransition(
|
||||
const result = await this.workflowService.processTransition(
|
||||
instanceId,
|
||||
dto.action,
|
||||
userId,
|
||||
dto.comment,
|
||||
dto.payload
|
||||
dto.payload,
|
||||
dto.attachmentPublicIds // ADR-021: step-specific attachments
|
||||
);
|
||||
|
||||
// เก็บใน Redis 24 ชั่วโมง (86400 วินาที = 86400000 ms ใน cache-manager v7)
|
||||
await this.cacheManager.set(cacheKey, result, 86_400_000);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@Get('instances/:id/history')
|
||||
@ApiOperation({
|
||||
summary: 'ดึงประวัติ Workflow พร้อมไฟล์แนบประจำแต่ละ Step (ADR-021)',
|
||||
})
|
||||
@ApiParam({ name: 'id', description: 'Workflow Instance ID (UUID)' })
|
||||
@RequirePermission('document.view')
|
||||
async getHistory(@Param('id') instanceId: string) {
|
||||
return this.workflowService.getHistoryWithAttachments(instanceId);
|
||||
}
|
||||
|
||||
@Get('instances/:id/actions')
|
||||
|
||||
@@ -7,26 +7,37 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { WorkflowDefinition } from './entities/workflow-definition.entity';
|
||||
import { WorkflowHistory } from './entities/workflow-history.entity';
|
||||
import { WorkflowInstance } from './entities/workflow-instance.entity';
|
||||
import { Attachment } from '../../common/file-storage/entities/attachment.entity';
|
||||
|
||||
// Services
|
||||
import { WorkflowDslService } from './workflow-dsl.service';
|
||||
import { WorkflowEngineService } from './workflow-engine.service';
|
||||
import { WorkflowEventService } from './workflow-event.service'; // [NEW]
|
||||
|
||||
// Guards
|
||||
import { WorkflowTransitionGuard } from './guards/workflow-transition.guard';
|
||||
|
||||
// Controllers
|
||||
import { UserModule } from '../user/user.module';
|
||||
import { WorkflowEngineController } from './workflow-engine.controller';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
WorkflowDefinition,
|
||||
WorkflowInstance,
|
||||
WorkflowHistory,
|
||||
Attachment, // ADR-021: ใช้ link attachments ประจำ Step
|
||||
]),
|
||||
UserModule,
|
||||
],
|
||||
controllers: [WorkflowEngineController],
|
||||
providers: [WorkflowEngineService, WorkflowDslService, WorkflowEventService],
|
||||
providers: [
|
||||
WorkflowEngineService,
|
||||
WorkflowDslService,
|
||||
WorkflowEventService,
|
||||
WorkflowTransitionGuard,
|
||||
],
|
||||
exports: [WorkflowEngineService], // Export Service ให้ Module อื่น (Correspondence, RFA) เรียกใช้
|
||||
})
|
||||
export class WorkflowEngineModule {}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
WorkflowStatus,
|
||||
} from './entities/workflow-instance.entity';
|
||||
import { WorkflowHistory } from './entities/workflow-history.entity';
|
||||
import { Attachment } from '../../common/file-storage/entities/attachment.entity';
|
||||
import { WorkflowDslService } from './workflow-dsl.service';
|
||||
import { WorkflowEventService } from './workflow-event.service';
|
||||
import { NotFoundException } from '../../common/exceptions';
|
||||
@@ -30,6 +31,7 @@ describe('WorkflowEngineService', () => {
|
||||
manager: {
|
||||
findOne: jest.fn(),
|
||||
save: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -81,6 +83,14 @@ describe('WorkflowEngineService', () => {
|
||||
useValue: {
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
find: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(Attachment),
|
||||
useValue: {
|
||||
find: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
},
|
||||
{ provide: WorkflowDslService, useValue: mockDslService },
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { NotFoundException, WorkflowException } from '../../common/exceptions';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
import { DataSource, In, Repository } from 'typeorm';
|
||||
// Entities
|
||||
import { WorkflowDefinition } from './entities/workflow-definition.entity';
|
||||
import { WorkflowHistory } from './entities/workflow-history.entity';
|
||||
@@ -11,11 +11,13 @@ import {
|
||||
WorkflowInstance,
|
||||
WorkflowStatus,
|
||||
} from './entities/workflow-instance.entity';
|
||||
import { Attachment } from '../../common/file-storage/entities/attachment.entity';
|
||||
|
||||
// Services & Interfaces
|
||||
import { CreateWorkflowDefinitionDto } from './dto/create-workflow-definition.dto';
|
||||
import { EvaluateWorkflowDto } from './dto/evaluate-workflow.dto';
|
||||
import { UpdateWorkflowDefinitionDto } from './dto/update-workflow-definition.dto';
|
||||
import { WorkflowHistoryItemDto } from './dto/workflow-history-item.dto';
|
||||
import {
|
||||
CompiledWorkflow,
|
||||
RawEvent,
|
||||
@@ -48,6 +50,9 @@ export class WorkflowEngineService {
|
||||
private readonly instanceRepo: Repository<WorkflowInstance>,
|
||||
@InjectRepository(WorkflowHistory)
|
||||
private readonly historyRepo: Repository<WorkflowHistory>,
|
||||
// ADR-021: Repository สำหรับ Link Attachments ประจำ Step
|
||||
@InjectRepository(Attachment)
|
||||
private readonly attachmentRepo: Repository<Attachment>,
|
||||
private readonly dslService: WorkflowDslService,
|
||||
private readonly eventService: WorkflowEventService, // [NEW] Inject Service
|
||||
private readonly dataSource: DataSource // ใช้สำหรับ Transaction
|
||||
@@ -243,6 +248,42 @@ export class WorkflowEngineService {
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* ค้นหา Workflow Instance จาก entityType + entityId (ADR-021 / v1.8.7)
|
||||
* ใช้โดย TransmittalService และ CirculationService เพื่อ expose workflowInstanceId ใน response
|
||||
* คืนค่า null ถ้าไม่มี Instance (เช่น เอกสาร Draft ที่ยังไม่เริ่ม Workflow)
|
||||
*/
|
||||
async getInstanceByEntity(
|
||||
entityType: string,
|
||||
entityId: string
|
||||
): Promise<{
|
||||
id: string;
|
||||
currentState: string;
|
||||
availableActions: string[];
|
||||
} | null> {
|
||||
const instance = await this.instanceRepo.findOne({
|
||||
where: { entityType, entityId, status: WorkflowStatus.ACTIVE },
|
||||
relations: ['definition'],
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
|
||||
if (!instance) return null;
|
||||
|
||||
const compiled = instance.definition?.compiled as unknown as
|
||||
| CompiledWorkflow
|
||||
| undefined;
|
||||
const stateConfig = compiled?.states?.[instance.currentState];
|
||||
const availableActions = stateConfig?.transitions
|
||||
? Object.keys(stateConfig.transitions)
|
||||
: [];
|
||||
|
||||
return {
|
||||
id: instance.id,
|
||||
currentState: instance.currentState,
|
||||
availableActions,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* ดำเนินการเปลี่ยนสถานะ (Transition) ของ Instance จริงแบบ Transactional
|
||||
*/
|
||||
@@ -251,7 +292,9 @@ export class WorkflowEngineService {
|
||||
action: string,
|
||||
userId: number,
|
||||
comment?: string,
|
||||
payload: Record<string, unknown> = {}
|
||||
payload: Record<string, unknown> = {},
|
||||
// ADR-021: publicIds ของไฟล์แนบประจำ Step นี้ (Two-Phase upload ก่อนแล้ว)
|
||||
attachmentPublicIds?: string[]
|
||||
) {
|
||||
const queryRunner = this.dataSource.createQueryRunner();
|
||||
await queryRunner.connect();
|
||||
@@ -323,6 +366,15 @@ export class WorkflowEngineService {
|
||||
});
|
||||
await queryRunner.manager.save(history);
|
||||
|
||||
// ADR-021: ผูกไฟล์แนบประจำ Step นี้ (ทำในตัว Transaction เดียวกัน)
|
||||
if (attachmentPublicIds && attachmentPublicIds.length > 0) {
|
||||
await queryRunner.manager.update(
|
||||
Attachment,
|
||||
{ publicId: In(attachmentPublicIds) },
|
||||
{ workflowHistoryId: history.id }
|
||||
);
|
||||
}
|
||||
|
||||
await queryRunner.commitTransaction();
|
||||
|
||||
// [NEW] เก็บค่าไว้ Dispatch หลัง Commit
|
||||
@@ -380,6 +432,66 @@ export class WorkflowEngineService {
|
||||
);
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// [PART 2.5] ADR-021: Workflow History with Step Attachments
|
||||
// =================================================================
|
||||
|
||||
/**
|
||||
* ดึงประวัติ Workflow พร้อมไฟล์แนบประจำแต่ละ Step (2-query, ไม่มี N+1)
|
||||
* GET /instances/:id/history
|
||||
*/
|
||||
async getHistoryWithAttachments(
|
||||
instanceId: string
|
||||
): Promise<WorkflowHistoryItemDto[]> {
|
||||
const histories = await this.historyRepo.find({
|
||||
where: { instanceId },
|
||||
order: { createdAt: 'ASC' },
|
||||
});
|
||||
|
||||
if (histories.length === 0) return [];
|
||||
|
||||
// Batch-load attachments ครั้งเดียวเพื่อป้องกัน N+1
|
||||
const historyIds = histories.map((h) => h.id);
|
||||
const attachments = await this.attachmentRepo.find({
|
||||
where: { workflowHistoryId: In(historyIds) },
|
||||
select: [
|
||||
'publicId',
|
||||
'originalFilename',
|
||||
'mimeType',
|
||||
'fileSize',
|
||||
'workflowHistoryId',
|
||||
],
|
||||
});
|
||||
|
||||
// Group attachments ตาม workflowHistoryId
|
||||
const attByHistoryId = attachments.reduce<Record<string, Attachment[]>>(
|
||||
(acc, att) => {
|
||||
const key = att.workflowHistoryId!;
|
||||
if (!acc[key]) acc[key] = [];
|
||||
acc[key].push(att);
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
return histories.map((h) => ({
|
||||
id: h.id,
|
||||
fromState: h.fromState,
|
||||
toState: h.toState,
|
||||
action: h.action,
|
||||
actionByUserId: h.actionByUserId,
|
||||
comment: h.comment,
|
||||
metadata: h.metadata,
|
||||
attachments: (attByHistoryId[h.id] ?? []).map((att) => ({
|
||||
publicId: att.publicId,
|
||||
originalFilename: att.originalFilename,
|
||||
mimeType: att.mimeType,
|
||||
fileSize: att.fileSize,
|
||||
})),
|
||||
createdAt: h.createdAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// [PART 3] Legacy Support (Backward Compatibility)
|
||||
// รักษา Logic เดิมไว้เพื่อให้ Module อื่น (Correspondence/RFA) ทำงานต่อได้
|
||||
|
||||
Reference in New Issue
Block a user