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
@@ -8,13 +8,22 @@ import {
ParseIntPipe,
UseGuards,
Patch,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import {
ApiTags,
ApiOperation,
ApiBearerAuth,
ApiParam,
ApiBody,
} from '@nestjs/swagger';
import { CirculationService } from './circulation.service';
import { CreateCirculationDto } from './dto/create-circulation.dto';
import { UpdateCirculationRoutingDto } from './dto/update-circulation-routing.dto';
import { SearchCirculationDto } from './dto/search-circulation.dto';
import { ReassignRoutingDto } from './dto/reassign-routing.dto';
import { ForceCloseCirculationDto } from './dto/force-close-circulation.dto';
import { User } from '../user/entities/user.entity';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
@@ -63,4 +72,43 @@ export class CirculationController {
) {
return this.circulationService.updateRoutingStatus(id, updateDto, user);
}
@Patch(':uuid/routing/:routingId/reassign')
@ApiOperation({
summary:
'Re-assign routing to new user when assignee is deactivated (EC-CIRC-001)',
})
@ApiParam({ name: 'uuid', description: 'Circulation publicId' })
@ApiParam({ name: 'routingId', description: 'CirculationRouting INT id' })
@ApiBody({ type: ReassignRoutingDto })
@RequirePermission('circulation.manage')
@Audit('circulation.reassign', 'circulation')
reassignRouting(
@Param('routingId', ParseIntPipe) routingId: number,
@Body() dto: ReassignRoutingDto,
@CurrentUser() user: User
) {
return this.circulationService.reassignRouting(
routingId,
dto.newAssigneeId,
user
);
}
@Post(':uuid/force-close')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Force close a Circulation with mandatory reason (EC-CIRC-002)',
})
@ApiParam({ name: 'uuid', description: 'Circulation publicId' })
@ApiBody({ type: ForceCloseCirculationDto })
@RequirePermission('circulation.manage')
@Audit('circulation.force_close', 'circulation')
forceClose(
@Param('uuid', ParseUuidPipe) uuid: string,
@Body() dto: ForceCloseCirculationDto,
@CurrentUser() user: User
) {
return this.circulationService.forceClose(uuid, dto.reason, user);
}
}
@@ -0,0 +1,249 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { CirculationService } from './circulation.service';
import { Circulation } from './entities/circulation.entity';
import { CirculationRouting } from './entities/circulation-routing.entity';
import { CirculationStatusCode } from './entities/circulation-status-code.entity';
import { DocumentNumberingService } from '../document-numbering/services/document-numbering.service';
import { UuidResolverService } from '../../common/services/uuid-resolver.service';
import { UserService } from '../user/user.service';
import { WorkflowEngineService } from '../workflow-engine/workflow-engine.service';
import {
ValidationException,
NotFoundException,
} from '../../common/exceptions';
import { User } from '../user/entities/user.entity';
describe('CirculationService', () => {
let service: CirculationService;
let circulationRepo: { findOne: jest.Mock; save: jest.Mock };
let routingRepo: { findOne: jest.Mock; save: jest.Mock };
let dataSource: { createQueryRunner: jest.Mock };
let uuidResolver: { resolveUserId: jest.Mock };
let workflowEngine: { getInstanceByEntity: jest.Mock };
const mockUser: Partial<User> = { user_id: 1, username: 'admin' };
const mockQueryRunner = {
connect: jest.fn(),
startTransaction: jest.fn(),
commitTransaction: jest.fn(),
rollbackTransaction: jest.fn(),
release: jest.fn(),
manager: { save: jest.fn() },
};
beforeEach(async () => {
circulationRepo = { findOne: jest.fn(), save: jest.fn() };
routingRepo = { findOne: jest.fn(), save: jest.fn() };
uuidResolver = { resolveUserId: jest.fn() };
workflowEngine = { getInstanceByEntity: jest.fn() };
dataSource = { createQueryRunner: jest.fn(() => mockQueryRunner) };
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
CirculationService,
{ provide: getRepositoryToken(Circulation), useValue: circulationRepo },
{
provide: getRepositoryToken(CirculationRouting),
useValue: routingRepo,
},
{
provide: getRepositoryToken(CirculationStatusCode),
useValue: { findOne: jest.fn() },
},
{ provide: DataSource, useValue: dataSource },
{ provide: DocumentNumberingService, useValue: {} },
{ provide: UuidResolverService, useValue: uuidResolver },
{
provide: UserService,
useValue: { getUserPermissions: jest.fn().mockResolvedValue([]) },
},
{ provide: WorkflowEngineService, useValue: workflowEngine },
],
}).compile();
service = module.get<CirculationService>(CirculationService);
});
describe('reassignRouting() - EC-CIRC-001', () => {
it('reassigns a PENDING routing to a new user by UUID', async () => {
const mockRouting = {
id: 5,
status: 'PENDING',
assignedTo: 10,
circulation: {},
};
routingRepo.findOne.mockResolvedValue(mockRouting);
uuidResolver.resolveUserId.mockResolvedValue(99);
routingRepo.save.mockResolvedValue({ ...mockRouting, assignedTo: 99 });
const result = await service.reassignRouting(
5,
'new-user-uuid',
mockUser as User
);
expect(uuidResolver.resolveUserId).toHaveBeenCalledWith('new-user-uuid');
expect(routingRepo.save).toHaveBeenCalledWith(
expect.objectContaining({ assignedTo: 99 })
);
expect(result.assignedTo).toBe(99);
});
it('throws ValidationException when routing is not in PENDING status', async () => {
routingRepo.findOne.mockResolvedValue({
id: 5,
status: 'COMPLETED',
circulation: {},
});
await expect(
service.reassignRouting(5, 'new-user-uuid', mockUser as User)
).rejects.toThrow(ValidationException);
});
it('throws NotFoundException when routing does not exist', async () => {
routingRepo.findOne.mockResolvedValue(null);
await expect(
service.reassignRouting(999, 'new-user-uuid', mockUser as User)
).rejects.toThrow(NotFoundException);
});
});
describe('forceClose() - EC-CIRC-002', () => {
const uuid = '019circ-0000-7000-8000-000000000001';
const buildMockCirculation = () => ({
id: 100,
publicId: uuid,
circulationNo: 'CIRC-2026-001',
statusCode: 'OPEN',
routings: [
{ id: 1, status: 'PENDING', comments: null, completedAt: null },
{
id: 2,
status: 'COMPLETED',
comments: 'done',
completedAt: new Date(),
},
{ id: 3, status: 'IN_PROGRESS', comments: null, completedAt: null },
],
});
beforeEach(() => {
circulationRepo.findOne.mockResolvedValue(buildMockCirculation());
});
it('saves rejected routings and commits the transaction', async () => {
await service.forceClose(uuid, 'Budget cut', mockUser as User);
expect(mockQueryRunner.manager.save).toHaveBeenCalledTimes(3);
expect(mockQueryRunner.commitTransaction).toHaveBeenCalled();
});
it('returns success=true and affectedRoutings count of 2', async () => {
const result = await service.forceClose(
uuid,
'Cost savings',
mockUser as User
);
expect(result.success).toBe(true);
expect(result.affectedRoutings).toBe(2);
});
it('throws ValidationException when reason is an empty string', async () => {
await expect(
service.forceClose(uuid, '', mockUser as User)
).rejects.toThrow(ValidationException);
});
it('throws ValidationException when reason is only whitespace', async () => {
await expect(
service.forceClose(uuid, ' ', mockUser as User)
).rejects.toThrow(ValidationException);
});
it('throws ValidationException when circulation is already COMPLETED', async () => {
circulationRepo.findOne.mockResolvedValue({
...buildMockCirculation(),
statusCode: 'COMPLETED',
});
await expect(
service.forceClose(uuid, 'Trying to close completed', mockUser as User)
).rejects.toThrow(ValidationException);
});
it('throws ValidationException when circulation is already CANCELLED', async () => {
circulationRepo.findOne.mockResolvedValue({
...buildMockCirculation(),
statusCode: 'CANCELLED',
});
await expect(
service.forceClose(uuid, 'Already cancelled', mockUser as User)
).rejects.toThrow(ValidationException);
});
it('throws NotFoundException when circulation is not found', async () => {
circulationRepo.findOne.mockResolvedValue(null);
await expect(
service.forceClose(uuid, 'Not found', mockUser as User)
).rejects.toThrow(NotFoundException);
});
});
describe('findOneByUuid() - EC-CIRC-003 workflowInstanceId + deadlineDate', () => {
it('exposes workflowInstanceId and deadlineDate when a workflow instance exists', async () => {
circulationRepo.findOne.mockResolvedValue({
id: 100,
publicId: '019circ-test-uuid',
circulationNo: 'CIRC-001',
subject: 'Test',
statusCode: 'OPEN',
routings: [],
deadlineDate: '2026-04-20',
});
workflowEngine.getInstanceByEntity.mockResolvedValue({
id: 'wf-circ-uuid-001',
currentState: 'OPEN',
availableActions: [],
});
const result = await service.findOneByUuid('019circ-test-uuid');
expect(workflowEngine.getInstanceByEntity).toHaveBeenCalledWith(
'circulation',
'100'
);
expect(result.workflowInstanceId).toBe('wf-circ-uuid-001');
expect(result.workflowState).toBe('OPEN');
expect((result as { deadlineDate?: string }).deadlineDate).toBe(
'2026-04-20'
);
});
it('returns empty availableActions and undefined workflowInstanceId in draft state', async () => {
circulationRepo.findOne.mockResolvedValue({
id: 101,
publicId: '019circ-draft-uuid',
circulationNo: 'CIRC-002',
statusCode: 'DRAFT',
routings: [],
});
workflowEngine.getInstanceByEntity.mockResolvedValue(null);
const result = await service.findOneByUuid('019circ-draft-uuid');
expect(result.workflowInstanceId).toBeUndefined();
expect(result.availableActions).toEqual([]);
});
});
});
@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import {
NotFoundException,
PermissionException,
@@ -16,9 +16,12 @@ import { SearchCirculationDto } from './dto/search-circulation.dto';
import { DocumentNumberingService } from '../document-numbering/services/document-numbering.service';
import { UuidResolverService } from '../../common/services/uuid-resolver.service';
import { UserService } from '../user/user.service';
import { WorkflowEngineService } from '../workflow-engine/workflow-engine.service';
@Injectable()
export class CirculationService {
private readonly logger = new Logger(CirculationService.name);
private async hasSystemManageAllPermission(userId: number): Promise<boolean> {
const permissions = await this.userService.getUserPermissions(userId);
return permissions.includes('system.manage_all');
@@ -32,7 +35,8 @@ export class CirculationService {
private numberingService: DocumentNumberingService,
private dataSource: DataSource,
private uuidResolver: UuidResolverService,
private userService: UserService
private userService: UserService,
private workflowEngine: WorkflowEngineService
) {}
async create(createDto: CreateCirculationDto, user: User) {
@@ -184,7 +188,115 @@ export class CirculationService {
});
if (!circulation)
throw new NotFoundException(`Circulation publicId ${publicId} not found`);
return circulation;
// v1.8.7: ดึง Workflow Instance สำหรับ Circulation นี้ (nullable — ก่อน Submit ไม่มี Instance)
const wfInstance = await this.workflowEngine.getInstanceByEntity(
'circulation',
circulation.id.toString()
);
return {
...circulation,
workflowInstanceId: wfInstance?.id,
workflowState: wfInstance?.currentState,
availableActions: wfInstance?.availableActions ?? [],
};
}
/**
* EC-CIRC-001: Re-assign routing เมื่อ Assignee ถูก Deactivate (v1.8.7)
* ต้องมีสิทธิ์ circulation.manage
*/
async reassignRouting(
routingId: number,
newAssigneePublicId: string,
user: User
) {
const routing = await this.routingRepo.findOne({
where: { id: routingId },
relations: ['circulation'],
});
if (!routing)
throw new NotFoundException('Circulation Routing', String(routingId));
if (routing.status !== 'PENDING') {
throw new ValidationException(
`Routing ID ${routingId} ไม่ได้อยู่ใน PENDING จึงไม่สามารถ Re-assign ได้`
);
}
const newAssigneeId =
await this.uuidResolver.resolveUserId(newAssigneePublicId);
routing.assignedTo = newAssigneeId;
const saved = await this.routingRepo.save(routing);
this.logger.log(
`Circulation routing ${routingId} reassigned to user ${newAssigneeId} by ${user.user_id}`
);
return saved;
}
/**
* EC-CIRC-002: Force Close Circulation พร้อม reason บังคับ (v1.8.7)
* ปิด routing ที่ PENDING ทั้งหมด + เปลี่ยน statusCode เป็น CANCELLED
* ต้องมีสิทธิ์ circulation.manage
*/
async forceClose(publicId: string, reason: string, user: User) {
if (!reason || reason.trim().length === 0) {
throw new ValidationException('กรุณาระบุเหตุผลในการปิดใบเวียนแบบบังคับ');
}
const circulation = await this.circulationRepo.findOne({
where: { publicId },
relations: ['routings'],
});
if (!circulation)
throw new NotFoundException(`Circulation publicId ${publicId}`);
if (
circulation.statusCode === 'COMPLETED' ||
circulation.statusCode === 'CANCELLED'
) {
throw new ValidationException(
`ใบเวียน ${circulation.circulationNo} ปิดไปแล้ว (${circulation.statusCode})`
);
}
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
// ปิด routing ที่ยัง PENDING ทั้งหมด
const pendingRoutings = circulation.routings.filter(
(r) => r.status === 'PENDING' || r.status === 'IN_PROGRESS'
);
for (const routing of pendingRoutings) {
routing.status = 'REJECTED';
routing.comments = `Force closed by user ${user.user_id}: ${reason}`;
routing.completedAt = new Date();
await queryRunner.manager.save(routing);
}
// อัปเดตสถานะ Circulation เป็น CANCELLED
circulation.statusCode = 'CANCELLED';
circulation.closedAt = new Date();
await queryRunner.manager.save(circulation);
await queryRunner.commitTransaction();
this.logger.log(
`Circulation ${publicId} force-closed by user ${user.user_id}. Reason: ${reason}`
);
return { success: true, affectedRoutings: pendingRoutings.length };
} catch (err) {
await queryRunner.rollbackTransaction();
this.logger.error(
`Force close failed for ${publicId}: ${(err as Error).message}`
);
throw err;
} finally {
await queryRunner.release();
}
}
// ✅ Logic อัปเดตสถานะและปิดงาน
@@ -0,0 +1,10 @@
import { IsString, IsNotEmpty, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class ForceCloseCirculationDto {
@ApiProperty({ description: 'เหตุผลการปิดใบเวียนแบบบังคับ (บังคับกรอก)' })
@IsString()
@IsNotEmpty()
@MinLength(5)
reason!: string;
}
@@ -0,0 +1,11 @@
import { IsNotEmpty, IsUUID } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class ReassignRoutingDto {
@ApiProperty({
description: 'publicId (UUID) ของผู้ใช้คนใหม่ที่ได้รับมอบหมาย',
})
@IsUUID('all')
@IsNotEmpty()
newAssigneeId!: string;
}
@@ -46,6 +46,9 @@ export class Circulation extends UuidBaseEntity {
@Column({ name: 'closed_at', type: 'timestamp', nullable: true })
closedAt?: Date;
@Column({ name: 'deadline_date', type: 'date', nullable: true })
deadlineDate?: string;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@@ -6,6 +6,8 @@ import {
Param,
UseGuards,
Query,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { TransmittalService } from './transmittal.service';
import { CreateTransmittalDto } from './dto/create-transmittal.dto';
@@ -23,6 +25,7 @@ import {
} from '@nestjs/swagger';
import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe';
import { ProjectService } from '../project/project.service';
import { Audit } from '../../common/decorators/audit.decorator';
@ApiTags('Transmittals')
@ApiBearerAuth()
@@ -61,11 +64,29 @@ export class TransmittalController {
@Get(':uuid')
@ApiOperation({ summary: 'Get Transmittal details' })
@ApiParam({
name: 'publicId',
name: 'uuid',
description: 'Transmittal publicId (from correspondences.publicId)',
})
@RequirePermission('document.view')
findOne(@Param('uuid', ParseUuidPipe) uuid: string) {
return this.transmittalService.findOneByUuid(uuid);
}
@Post(':uuid/submit')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Submit Transmittal to Workflow (with EC-RFA-004 validation)',
})
@ApiParam({
name: 'uuid',
description: 'Transmittal publicId (from correspondences.publicId)',
})
@RequirePermission('document.manage')
@Audit('transmittal.submit', 'transmittal')
submit(
@Param('uuid', ParseUuidPipe) uuid: string,
@CurrentUser() user: User
) {
return this.transmittalService.submit(uuid, user);
}
}
@@ -5,12 +5,14 @@ import { TransmittalItem } from './entities/transmittal-item.entity';
import { Correspondence } from '../correspondence/entities/correspondence.entity';
import { CorrespondenceType } from '../correspondence/entities/correspondence-type.entity';
import { CorrespondenceStatus } from '../correspondence/entities/correspondence-status.entity';
import { CorrespondenceRevision } from '../correspondence/entities/correspondence-revision.entity';
import { TransmittalService } from './transmittal.service';
import { TransmittalController } from './transmittal.controller';
import { DocumentNumberingModule } from '../document-numbering/document-numbering.module';
import { ProjectModule } from '../project/project.module';
import { UserModule } from '../user/user.module';
import { SearchModule } from '../search/search.module';
import { WorkflowEngineModule } from '../workflow-engine/workflow-engine.module';
@Module({
imports: [
@@ -20,11 +22,13 @@ import { SearchModule } from '../search/search.module';
Correspondence,
CorrespondenceType,
CorrespondenceStatus,
CorrespondenceRevision,
]),
DocumentNumberingModule,
ProjectModule,
UserModule,
SearchModule,
WorkflowEngineModule,
],
controllers: [TransmittalController],
providers: [TransmittalService],
@@ -0,0 +1,261 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { TransmittalService } from './transmittal.service';
import { Transmittal } from './entities/transmittal.entity';
import { TransmittalItem } from './entities/transmittal-item.entity';
import { CorrespondenceType } from '../correspondence/entities/correspondence-type.entity';
import { CorrespondenceStatus } from '../correspondence/entities/correspondence-status.entity';
import { CorrespondenceRevision } from '../correspondence/entities/correspondence-revision.entity';
import { DocumentNumberingService } from '../document-numbering/services/document-numbering.service';
import { UuidResolverService } from '../../common/services/uuid-resolver.service';
import { UserService } from '../user/user.service';
import { WorkflowEngineService } from '../workflow-engine/workflow-engine.service';
import {
ValidationException,
NotFoundException,
} from '../../common/exceptions';
import { User } from '../user/entities/user.entity';
describe('TransmittalService', () => {
let service: TransmittalService;
let transmittalRepo: { findOne: jest.Mock };
let revisionRepo: {
findOne: jest.Mock;
createQueryBuilder: jest.Mock;
save: jest.Mock;
};
let statusRepo: { findOne: jest.Mock };
let dataSource: {
manager: { findOne: jest.Mock };
createQueryRunner: jest.Mock;
};
let workflowEngine: {
getInstanceByEntity: jest.Mock;
createInstance: jest.Mock;
processTransition: jest.Mock;
};
const mockUser: Partial<User> = {
user_id: 1,
username: 'testuser',
primaryOrganizationId: 10,
};
const mockTransmittal = {
correspondenceId: 99,
items: [{ itemCorrespondenceId: 201 }, { itemCorrespondenceId: 202 }],
};
const mockQB = {
innerJoin: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
leftJoinAndSelect: jest.fn().mockReturnThis(),
getMany: jest.fn(),
};
const mockQueryRunner = {
connect: jest.fn(),
startTransaction: jest.fn(),
commitTransaction: jest.fn(),
rollbackTransaction: jest.fn(),
release: jest.fn(),
manager: { save: jest.fn() },
};
beforeEach(async () => {
transmittalRepo = { findOne: jest.fn() };
revisionRepo = {
findOne: jest.fn(),
createQueryBuilder: jest.fn(() => mockQB),
save: jest.fn(),
};
statusRepo = { findOne: jest.fn() };
dataSource = {
manager: { findOne: jest.fn() },
createQueryRunner: jest.fn(() => mockQueryRunner),
};
workflowEngine = {
getInstanceByEntity: jest.fn(),
createInstance: jest.fn(),
processTransition: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
TransmittalService,
{ provide: getRepositoryToken(Transmittal), useValue: transmittalRepo },
{
provide: getRepositoryToken(TransmittalItem),
useValue: { find: jest.fn() },
},
{
provide: getRepositoryToken(CorrespondenceType),
useValue: { findOne: jest.fn() },
},
{
provide: getRepositoryToken(CorrespondenceStatus),
useValue: statusRepo,
},
{
provide: getRepositoryToken(CorrespondenceRevision),
useValue: revisionRepo,
},
{ provide: DataSource, useValue: dataSource },
{ provide: DocumentNumberingService, useValue: {} },
{ provide: UuidResolverService, useValue: {} },
{
provide: UserService,
useValue: { getUserPermissions: jest.fn().mockResolvedValue([]) },
},
{ provide: WorkflowEngineService, useValue: workflowEngine },
],
}).compile();
service = module.get<TransmittalService>(TransmittalService);
});
describe('submit() - EC-RFA-004', () => {
const uuid = '019abc01-0000-7000-8000-000000000001';
beforeEach(() => {
dataSource.manager.findOne.mockResolvedValue({
id: 99,
correspondenceNumber: 'TRN-2026-001',
});
transmittalRepo.findOne.mockResolvedValue(mockTransmittal);
});
it('throws ValidationException when an item correspondence is in DRAFT state (EC-RFA-004)', async () => {
mockQB.getMany.mockResolvedValue([
{ correspondence: { correspondenceNumber: 'RFA-2026-001' } },
]);
await expect(service.submit(uuid, mockUser as User)).rejects.toThrow(
ValidationException
);
});
it('includes the draft document number in the error response', async () => {
mockQB.getMany.mockResolvedValue([
{ correspondence: { correspondenceNumber: 'RFA-2026-001' } },
]);
let thrownError: unknown;
try {
await service.submit(uuid, mockUser as User);
} catch (e) {
thrownError = e;
}
expect(thrownError).toBeInstanceOf(ValidationException);
const res = (thrownError as ValidationException).getResponse();
const resStr = typeof res === 'string' ? res : JSON.stringify(res);
expect(resStr).toContain('RFA-2026-001');
});
it('creates a workflow instance when no items are in DRAFT state', async () => {
mockQB.getMany.mockResolvedValue([]);
workflowEngine.createInstance.mockResolvedValue({
id: 'wf-instance-uuid-001',
});
workflowEngine.processTransition.mockResolvedValue({
nextState: 'IN_REVIEW',
});
revisionRepo.findOne.mockResolvedValue({
id: 55,
correspondenceId: 99,
isCurrent: true,
statusId: 1,
});
statusRepo.findOne
.mockResolvedValueOnce({ id: 1, statusCode: 'DRAFT' })
.mockResolvedValueOnce({ id: 2, statusCode: 'SUBMITTED' });
const result = await service.submit(uuid, mockUser as User);
expect(workflowEngine.createInstance).toHaveBeenCalledWith(
'TRANSMITTAL_FLOW_V1',
'transmittal',
'99',
expect.objectContaining({ ownerId: 1 })
);
expect(result).toEqual({
instanceId: 'wf-instance-uuid-001',
currentState: 'IN_REVIEW',
});
});
it('throws NotFoundException when correspondence publicId is not found', async () => {
dataSource.manager.findOne.mockResolvedValue(null);
await expect(service.submit(uuid, mockUser as User)).rejects.toThrow(
NotFoundException
);
});
it('throws NotFoundException when transmittal record is not found', async () => {
transmittalRepo.findOne.mockResolvedValue(null);
await expect(service.submit(uuid, mockUser as User)).rejects.toThrow(
NotFoundException
);
});
});
describe('findOneByUuid() - workflowInstanceId exposure (ADR-021)', () => {
const uuid = '019abc02-0000-7000-8000-000000000002';
it('returns workflowInstanceId and workflowState when a workflow instance exists', async () => {
dataSource.manager.findOne.mockResolvedValue({ id: 99 });
transmittalRepo.findOne.mockResolvedValue({
correspondenceId: 99,
transmittalNo: 'TRN-001',
subject: 'Test',
correspondence: {
id: 99,
publicId: uuid,
correspondenceNumber: 'TRN-001',
},
items: [],
});
workflowEngine.getInstanceByEntity.mockResolvedValue({
id: 'wf-uuid-123',
currentState: 'IN_REVIEW',
availableActions: ['APPROVE', 'REJECT'],
});
const result = await service.findOneByUuid(uuid);
expect(workflowEngine.getInstanceByEntity).toHaveBeenCalledWith(
'transmittal',
'99'
);
expect(result.workflowInstanceId).toBe('wf-uuid-123');
expect(result.workflowState).toBe('IN_REVIEW');
expect(result.availableActions).toEqual(['APPROVE', 'REJECT']);
});
it('returns undefined workflowInstanceId when no workflow instance exists (Draft state)', async () => {
dataSource.manager.findOne.mockResolvedValue({ id: 99 });
transmittalRepo.findOne.mockResolvedValue({
correspondenceId: 99,
transmittalNo: 'TRN-001',
items: [],
correspondence: {
id: 99,
publicId: uuid,
correspondenceNumber: 'TRN-001',
},
});
workflowEngine.getInstanceByEntity.mockResolvedValue(null);
const result = await service.findOneByUuid(uuid);
expect(result.workflowInstanceId).toBeUndefined();
expect(result.workflowState).toBeUndefined();
expect(result.availableActions).toEqual([]);
});
});
});
@@ -23,6 +23,7 @@ import { CorrespondenceStatus } from '../correspondence/entities/correspondence-
import { UuidResolverService } from '../../common/services/uuid-resolver.service';
import { CorrespondenceRecipient } from '../correspondence/entities/correspondence-recipient.entity';
import { UserService } from '../user/user.service';
import { WorkflowEngineService } from '../workflow-engine/workflow-engine.service';
@Injectable()
export class TransmittalService {
@@ -42,10 +43,13 @@ export class TransmittalService {
private typeRepo: Repository<CorrespondenceType>,
@InjectRepository(CorrespondenceStatus)
private statusRepo: Repository<CorrespondenceStatus>,
@InjectRepository(CorrespondenceRevision)
private revisionRepo: Repository<CorrespondenceRevision>,
private numberingService: DocumentNumberingService,
private dataSource: DataSource,
private uuidResolver: UuidResolverService,
private userService: UserService
private userService: UserService,
private workflowEngine: WorkflowEngineService
) {}
async create(
@@ -192,9 +196,15 @@ export class TransmittalService {
/**
* ADR-019: Find Transmittal by parent Correspondence publicId (public identifier).
* Resolves correspondence.publicId → internal correspondenceId (INT)
* v1.8.7: Exposes workflowInstanceId, workflowState, availableActions via WorkflowEngineService
*/
async findOneByUuid(publicId: string): Promise<Transmittal> {
async findOneByUuid(publicId: string): Promise<
Transmittal & {
workflowInstanceId?: string;
workflowState?: string;
availableActions?: string[];
}
> {
const correspondence = await this.dataSource.manager.findOne(
Correspondence,
{ where: { publicId }, select: ['id'] }
@@ -204,7 +214,20 @@ export class TransmittalService {
`Transmittal with publicId ${publicId} not found`
);
}
return this.findOne(correspondence.id);
const transmittal = await this.findOne(correspondence.id);
// v1.8.7: ดึง Workflow Instance สำหรับ Transmittal นี้ (nullable — Draft ไม่มี Instance)
const wfInstance = await this.workflowEngine.getInstanceByEntity(
'transmittal',
correspondence.id.toString()
);
return {
...transmittal,
workflowInstanceId: wfInstance?.id,
workflowState: wfInstance?.currentState,
availableActions: wfInstance?.availableActions ?? [],
};
}
async findOne(id: number): Promise<Transmittal> {
@@ -217,6 +240,86 @@ export class TransmittalService {
return transmittal;
}
/**
* Submit Transmittal — ตรวจสอบ EC-RFA-004 ก่อนเริ่ม Workflow (v1.8.7)
* EC-RFA-004: ทุก item ต้องไม่อยู่ใน DRAFT ก่อน Submit
*/
async submit(
uuid: string,
user: User
): Promise<{ instanceId: string; currentState: string }> {
const correspondence = await this.dataSource.manager.findOne(
Correspondence,
{ where: { publicId: uuid }, select: ['id', 'correspondenceNumber'] }
);
if (!correspondence)
throw new NotFoundException(`Transmittal publicId ${uuid}`);
const transmittal = await this.transmittalRepo.findOne({
where: { correspondenceId: correspondence.id },
relations: ['items'],
});
if (!transmittal) throw new NotFoundException('Transmittal', uuid);
// EC-RFA-004: ตรวจสอบว่า item ทุกชิ้นไม่อยู่ใน DRAFT
if (transmittal.items && transmittal.items.length > 0) {
const itemCorrIds = transmittal.items.map((i) => i.itemCorrespondenceId);
const draftRevisions = await this.revisionRepo
.createQueryBuilder('rev')
.innerJoin('rev.status', 'status')
.where('rev.correspondenceId IN (:...ids)', { ids: itemCorrIds })
.andWhere('rev.isCurrent = :isCurrent', { isCurrent: true })
.andWhere('status.statusCode = :code', { code: 'DRAFT' })
.leftJoinAndSelect('rev.correspondence', 'corr')
.getMany();
if (draftRevisions.length > 0) {
const draftDocNo =
draftRevisions[0]?.correspondence?.correspondenceNumber ?? 'Unknown';
throw new ValidationException(
`RFA ${draftDocNo} ยังอยู่ใน Draft กรุณา Submit ก่อน`
);
}
}
// เริ่ม Workflow Instance สำหรับ Transmittal
const statusDraft = await this.statusRepo.findOne({
where: { statusCode: 'DRAFT' },
});
const instance = await this.workflowEngine.createInstance(
'TRANSMITTAL_FLOW_V1',
'transmittal',
correspondence.id.toString(),
{ ownerId: user.user_id }
);
const result = await this.workflowEngine.processTransition(
instance.id,
'SUBMIT',
user.user_id,
'Transmittal Submitted'
);
// Sync สถานะกลับที่ Correspondence Revision
if (statusDraft) {
const revision = await this.revisionRepo.findOne({
where: { correspondenceId: correspondence.id, isCurrent: true },
});
if (revision) {
const submittedStatus = await this.statusRepo.findOne({
where: { statusCode: 'SUBMITTED' },
});
if (submittedStatus) {
revision.statusId = submittedStatus.id;
await this.revisionRepo.save(revision);
}
}
}
this.logger.log(`Transmittal ${uuid} submitted — instance ${instance.id}`);
return { instanceId: instance.id, currentState: result.nextState };
}
async findAll(query: SearchTransmittalDto) {
const { page = 1, limit = 20, projectId, search } = query;
const skip = ((page ?? 1) - 1) * (limit ?? 20);
@@ -239,6 +342,13 @@ export class TransmittalService {
});
}
// B3: purpose filter (EC-RFA-004 aligned)
if (query.purpose) {
queryBuilder.andWhere('transmittal.purpose = :purpose', {
purpose: query.purpose,
});
}
if (search) {
queryBuilder.andWhere(
'(correspondence.correspondenceNumber LIKE :search OR revision.title LIKE :search)',
@@ -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) ทำงานต่อได้