690414:1113 Update README.md /.agents/skills, /.windsurf/workflows

This commit is contained in:
2026-04-14 11:13:42 +07:00
parent 02400fd88c
commit 6d45bdaeb5
194 changed files with 12708 additions and 8762 deletions
@@ -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) ทำงานต่อได้