690513:0920 Refactor Workflow module: Lint error #01
CI / CD Pipeline / build (push) Failing after 10m44s
CI / CD Pipeline / deploy (push) Has been skipped

This commit is contained in:
2026-05-13 09:20:49 +07:00
parent e218fc826c
commit 5537d20152
299 changed files with 27326 additions and 2501 deletions
@@ -46,7 +46,9 @@ export class ReviewTeamMember extends UuidBaseEntity {
createdAt!: Date;
// Relations
@ManyToOne(() => ReviewTeam, (team: ReviewTeam) => team.members, { onDelete: 'CASCADE' })
@ManyToOne(() => ReviewTeam, (team: ReviewTeam) => team.members, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'team_id' })
team!: ReviewTeam;
@@ -30,7 +30,11 @@ export class ReviewTeam extends UuidBaseEntity {
@Column({ length: 255, nullable: true })
description?: string;
@Column({ name: 'default_for_rfa_types', type: 'simple-array', nullable: true })
@Column({
name: 'default_for_rfa_types',
type: 'simple-array',
nullable: true,
})
defaultForRfaTypes?: string[]; // Auto-assign ให้ RFA type เช่น ['SDW','DDW']
@Column({ name: 'is_active', type: 'tinyint', default: 1 })
@@ -47,6 +51,10 @@ export class ReviewTeam extends UuidBaseEntity {
@JoinColumn({ name: 'project_id' })
project?: Project;
@OneToMany(() => ReviewTeamMember, (member: ReviewTeamMember) => member.team, { cascade: true })
@OneToMany(
() => ReviewTeamMember,
(member: ReviewTeamMember) => member.team,
{ cascade: true }
)
members?: ReviewTeamMember[];
}
@@ -1,5 +1,11 @@
// File: src/modules/review-team/review-task.service.ts
import { Injectable, Logger, NotFoundException, BadRequestException, ConflictException } from '@nestjs/common';
import {
Injectable,
Logger,
NotFoundException,
BadRequestException,
ConflictException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ReviewTask } from './entities/review-task.entity';
@@ -19,7 +25,7 @@ export class ReviewTaskService {
@InjectRepository(ReviewTask)
private readonly reviewTaskRepo: Repository<ReviewTask>,
@InjectRepository(ResponseCode)
private readonly responseCodeRepo: Repository<ResponseCode>,
private readonly responseCodeRepo: Repository<ResponseCode>
) {}
/**
@@ -41,8 +47,11 @@ export class ReviewTaskService {
.leftJoinAndSelect('task.team', 'team');
if (dto.rfaRevisionPublicId) {
qb.innerJoin('rfa_revisions', 'rev', 'rev.id = task.rfa_revision_id')
.where('rev.uuid = :uuid', { uuid: dto.rfaRevisionPublicId });
qb.innerJoin(
'rfa_revisions',
'rev',
'rev.id = task.rfa_revision_id'
).where('rev.uuid = :uuid', { uuid: dto.rfaRevisionPublicId });
}
if (dto.status) {
@@ -50,7 +59,9 @@ export class ReviewTaskService {
}
if (dto.assignedToUserPublicId) {
qb.andWhere('user.uuid = :userUuid', { userUuid: dto.assignedToUserPublicId });
qb.andWhere('user.uuid = :userUuid', {
userUuid: dto.assignedToUserPublicId,
});
}
if (dto.dueDateFrom) {
@@ -94,7 +105,8 @@ export class ReviewTaskService {
const total = tasks.length;
const completed = tasks.filter(
(t: ReviewTask) =>
t.status === ReviewTaskStatus.COMPLETED || t.status === ReviewTaskStatus.CANCELLED,
t.status === ReviewTaskStatus.COMPLETED ||
t.status === ReviewTaskStatus.CANCELLED
).length;
const pending = total - completed;
@@ -114,7 +126,7 @@ export class ReviewTaskService {
if (task.status !== ReviewTaskStatus.PENDING) {
throw new BadRequestException(
`Cannot start review: task is already ${task.status}`,
`Cannot start review: task is already ${task.status}`
);
}
@@ -126,7 +138,10 @@ export class ReviewTaskService {
* บันทึกผลการตรวจสอบ (FR-009, T069)
* ใช้ Optimistic Locking (@VersionColumn) ป้องกัน race condition (ADR-002)
*/
async completeReview(publicId: string, dto: CompleteReviewTaskDto): Promise<ReviewTask> {
async completeReview(
publicId: string,
dto: CompleteReviewTaskDto
): Promise<ReviewTask> {
const task = await this.findByPublicId(publicId);
if (
@@ -134,7 +149,7 @@ export class ReviewTaskService {
task.status === ReviewTaskStatus.CANCELLED
) {
throw new BadRequestException(
`Cannot complete review: task is already ${task.status}`,
`Cannot complete review: task is already ${task.status}`
);
}
@@ -145,7 +160,7 @@ export class ReviewTaskService {
if (!responseCode) {
throw new NotFoundException(
`Response Code not found: ${dto.responseCodePublicId}`,
`Response Code not found: ${dto.responseCodePublicId}`
);
}
@@ -154,7 +169,7 @@ export class ReviewTaskService {
ReviewTaskStatus.COMPLETED,
responseCode.id,
false, // requiresComments checked at controller level via ResponseCodeRule
dto.comments,
dto.comments
);
task.status = ReviewTaskStatus.COMPLETED;
@@ -168,9 +183,12 @@ export class ReviewTaskService {
return await this.reviewTaskRepo.save(task);
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
if (errorMessage.includes('OptimisticLock') || errorMessage.includes('version')) {
if (
errorMessage.includes('OptimisticLock') ||
errorMessage.includes('version')
) {
throw new ConflictException(
'Review task was modified concurrently. Please refresh and try again.',
'Review task was modified concurrently. Please refresh and try again.'
);
}
throw err;
@@ -56,7 +56,10 @@ export class ReviewTeamController {
* อัปเดต Review Team
*/
@Patch(':publicId')
update(@Param('publicId') publicId: string, @Body() dto: UpdateReviewTeamDto) {
update(
@Param('publicId') publicId: string,
@Body() dto: UpdateReviewTeamDto
) {
return this.reviewTeamService.update(publicId, dto);
}
@@ -65,7 +68,10 @@ export class ReviewTeamController {
* เพิ่มสมาชิก
*/
@Post(':publicId/members')
addMember(@Param('publicId') teamPublicId: string, @Body() dto: AddTeamMemberDto) {
addMember(
@Param('publicId') teamPublicId: string,
@Body() dto: AddTeamMemberDto
) {
return this.reviewTeamService.addMember(teamPublicId, dto);
}
@@ -76,7 +82,7 @@ export class ReviewTeamController {
@Delete(':publicId/members/:memberPublicId')
removeMember(
@Param('publicId') teamPublicId: string,
@Param('memberPublicId') memberPublicId: string,
@Param('memberPublicId') memberPublicId: string
) {
return this.reviewTeamService.removeMember(teamPublicId, memberPublicId);
}
@@ -30,14 +30,23 @@ import { UserModule } from '../user/user.module';
import { DistributionModule } from '../distribution/distribution.module';
// Queue constants
import { QUEUE_REMINDERS, QUEUE_VETO_NOTIFICATIONS } from '../common/constants/queue.constants';
import {
QUEUE_REMINDERS,
QUEUE_VETO_NOTIFICATIONS,
} from '../common/constants/queue.constants';
@Module({
imports: [
TypeOrmModule.forFeature([ReviewTeam, ReviewTeamMember, ReviewTask, User, Discipline]),
TypeOrmModule.forFeature([
ReviewTeam,
ReviewTeamMember,
ReviewTask,
User,
Discipline,
]),
BullModule.registerQueue(
{ name: QUEUE_REMINDERS },
{ name: QUEUE_VETO_NOTIFICATIONS },
{ name: QUEUE_VETO_NOTIFICATIONS }
),
ResponseCodeModule,
NotificationModule,
@@ -1,5 +1,10 @@
// File: src/modules/review-team/review-team.service.ts
import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common';
import {
Injectable,
Logger,
NotFoundException,
BadRequestException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ReviewTeam } from './entities/review-team.entity';
@@ -25,7 +30,7 @@ export class ReviewTeamService {
@InjectRepository(User)
private readonly userRepo: Repository<User>,
@InjectRepository(Discipline)
private readonly disciplineRepo: Repository<Discipline>,
private readonly disciplineRepo: Repository<Discipline>
) {}
/**
@@ -74,14 +79,17 @@ export class ReviewTeamService {
/**
* ดึง Teams ที่เป็น Default สำหรับ RFA type นั้นๆ (FR-002)
*/
async findDefaultForRfaType(rfaTypeCode: string, projectId: number): Promise<ReviewTeam[]> {
async findDefaultForRfaType(
rfaTypeCode: string,
projectId: number
): Promise<ReviewTeam[]> {
const teams = await this.teamRepo.find({
where: { projectId, isActive: true },
relations: ['members'],
});
return teams.filter(
(t: ReviewTeam) => t.defaultForRfaTypes?.includes(rfaTypeCode) ?? false,
(t: ReviewTeam) => t.defaultForRfaTypes?.includes(rfaTypeCode) ?? false
);
}
@@ -90,9 +98,11 @@ export class ReviewTeamService {
*/
async create(dto: CreateReviewTeamDto): Promise<ReviewTeam> {
// ตรวจสอบว่า project มีอยู่จริง (via publicId)
const project = await this.teamRepo.manager.getRepository('projects').findOne({
where: { uuid: dto.projectPublicId } as Record<string, unknown>,
});
const project = await this.teamRepo.manager
.getRepository('projects')
.findOne({
where: { uuid: dto.projectPublicId } as Record<string, unknown>,
});
if (!project) {
throw new NotFoundException(`Project not found: ${dto.projectPublicId}`);
@@ -112,12 +122,16 @@ export class ReviewTeamService {
/**
* อัปเดต Review Team
*/
async update(publicId: string, dto: UpdateReviewTeamDto): Promise<ReviewTeam> {
async update(
publicId: string,
dto: UpdateReviewTeamDto
): Promise<ReviewTeam> {
const team = await this.findByPublicId(publicId);
if (dto.name !== undefined) team.name = dto.name;
if (dto.description !== undefined) team.description = dto.description;
if (dto.defaultForRfaTypes !== undefined) team.defaultForRfaTypes = dto.defaultForRfaTypes;
if (dto.defaultForRfaTypes !== undefined)
team.defaultForRfaTypes = dto.defaultForRfaTypes;
if (dto.isActive !== undefined) team.isActive = dto.isActive;
return this.teamRepo.save(team);
@@ -126,27 +140,40 @@ export class ReviewTeamService {
/**
* เพิ่มสมาชิกใน Review Team (FR-001)
*/
async addMember(teamPublicId: string, dto: AddTeamMemberDto): Promise<ReviewTeamMember> {
async addMember(
teamPublicId: string,
dto: AddTeamMemberDto
): Promise<ReviewTeamMember> {
const team = await this.findByPublicId(teamPublicId);
// ตรวจสอบ User
const user = await this.userRepo.findOne({ where: { publicId: dto.userPublicId } });
if (!user) throw new NotFoundException(`User not found: ${dto.userPublicId}`);
const user = await this.userRepo.findOne({
where: { publicId: dto.userPublicId },
});
if (!user)
throw new NotFoundException(`User not found: ${dto.userPublicId}`);
// ตรวจสอบ Discipline
const discipline = await this.disciplineRepo.findOne({
where: { id: Number(dto.disciplinePublicId) },
});
if (!discipline) throw new NotFoundException(`Discipline not found: ${dto.disciplinePublicId}`);
if (!discipline)
throw new NotFoundException(
`Discipline not found: ${dto.disciplinePublicId}`
);
// ตรวจสอบซ้ำ
const existing = await this.memberRepo.findOne({
where: { teamId: team.id, userId: user.user_id, disciplineId: discipline.id },
where: {
teamId: team.id,
userId: user.user_id,
disciplineId: discipline.id,
},
});
if (existing) {
throw new BadRequestException(
`User ${dto.userPublicId} is already a member of this team for discipline ${dto.disciplinePublicId}`,
`User ${dto.userPublicId} is already a member of this team for discipline ${dto.disciplinePublicId}`
);
}
@@ -164,7 +191,10 @@ export class ReviewTeamService {
/**
* ลบสมาชิกออกจาก Review Team
*/
async removeMember(teamPublicId: string, memberPublicId: string): Promise<void> {
async removeMember(
teamPublicId: string,
memberPublicId: string
): Promise<void> {
const team = await this.findByPublicId(teamPublicId);
const member = await this.memberRepo.findOne({
where: { publicId: memberPublicId, teamId: team.id },
@@ -4,7 +4,10 @@ import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ReviewTask } from '../entities/review-task.entity';
import { ReviewTaskStatus, ConsensusDecision } from '../../common/enums/review.enums';
import {
ReviewTaskStatus,
ConsensusDecision,
} from '../../common/enums/review.enums';
export interface AggregateStatus {
total: number;
@@ -24,7 +27,7 @@ export class AggregateStatusService {
constructor(
@InjectRepository(ReviewTask)
private readonly taskRepo: Repository<ReviewTask>,
private readonly taskRepo: Repository<ReviewTask>
) {}
/**
@@ -47,12 +50,23 @@ export class AggregateStatusService {
for (const task of tasks) {
switch (task.status) {
case ReviewTaskStatus.COMPLETED: counts.completed++; break;
case ReviewTaskStatus.PENDING: counts.pending++; break;
case ReviewTaskStatus.IN_PROGRESS: counts.inProgress++; break;
case ReviewTaskStatus.DELEGATED: counts.delegated++; break;
case ReviewTaskStatus.EXPIRED: counts.expired++; break;
default: break;
case ReviewTaskStatus.COMPLETED:
counts.completed++;
break;
case ReviewTaskStatus.PENDING:
counts.pending++;
break;
case ReviewTaskStatus.IN_PROGRESS:
counts.inProgress++;
break;
case ReviewTaskStatus.DELEGATED:
counts.delegated++;
break;
case ReviewTaskStatus.EXPIRED:
counts.expired++;
break;
default:
break;
}
}
@@ -94,7 +108,7 @@ export class AggregateStatusService {
// All approved: Code 1A or 1B = APPROVED
const allApproved = tasks.every((t) =>
['1A', '1B'].includes(t.responseCode?.code ?? ''),
['1A', '1B'].includes(t.responseCode?.code ?? '')
);
if (allApproved) return ConsensusDecision.APPROVED;
@@ -6,7 +6,10 @@ import { Repository } from 'typeorm';
import { ReviewTask } from '../entities/review-task.entity';
import { AggregateStatusService } from './aggregate-status.service';
import { ApprovalListenerService } from '../../distribution/services/approval-listener.service';
import { ConsensusDecision, ReviewTaskStatus } from '../../common/enums/review.enums';
import {
ConsensusDecision,
ReviewTaskStatus,
} from '../../common/enums/review.enums';
export interface ConsensusResult {
decision: ConsensusDecision;
@@ -23,7 +26,7 @@ export class ConsensusService {
@InjectRepository(ReviewTask)
private readonly taskRepo: Repository<ReviewTask>,
private readonly aggregateStatusService: AggregateStatusService,
private readonly approvalListenerService: ApprovalListenerService,
private readonly approvalListenerService: ApprovalListenerService
) {}
/**
@@ -36,15 +39,17 @@ export class ConsensusService {
rfaRevisionPublicId: string;
projectId: number;
documentTypeCode: string;
},
}
): Promise<ConsensusResult> {
const isReady = await this.aggregateStatusService.isReadyForConsensus(rfaRevisionId);
const isReady =
await this.aggregateStatusService.isReadyForConsensus(rfaRevisionId);
const status = await this.aggregateStatusService.getForRevision(rfaRevisionId);
const status =
await this.aggregateStatusService.getForRevision(rfaRevisionId);
if (!isReady) {
this.logger.debug(
`Revision ${rfaRevisionId}: ${status.completed}/${status.total} tasks done — not ready for consensus`,
`Revision ${rfaRevisionId}: ${status.completed}/${status.total} tasks done — not ready for consensus`
);
return {
decision: ConsensusDecision.PENDING,
@@ -54,10 +59,11 @@ export class ConsensusService {
};
}
const decision = await this.aggregateStatusService.evaluateConsensus(rfaRevisionId);
const decision =
await this.aggregateStatusService.evaluateConsensus(rfaRevisionId);
this.logger.log(
`Revision ${rfaRevisionId}: consensus = ${decision} (${status.total} tasks)`,
`Revision ${rfaRevisionId}: consensus = ${decision} (${status.total} tasks)`
);
let triggeredDistribution = false;
@@ -8,7 +8,10 @@ import { Repository, EntityManager } from 'typeorm';
import { ReviewTeam } from '../entities/review-team.entity';
import { ReviewTeamMember } from '../entities/review-team-member.entity';
import { ReviewTask } from '../entities/review-task.entity';
import { ReviewTaskStatus } from '../../common/enums/review.enums';
import {
ReviewTaskStatus,
ReviewTeamMemberRole,
} from '../../common/enums/review.enums';
@Injectable()
export class TaskCreationService {
@@ -20,7 +23,7 @@ export class TaskCreationService {
@InjectRepository(ReviewTeamMember)
private readonly memberRepo: Repository<ReviewTeamMember>,
@InjectRepository(ReviewTask)
private readonly reviewTaskRepo: Repository<ReviewTask>,
private readonly reviewTaskRepo: Repository<ReviewTask>
) {}
/**
@@ -36,7 +39,7 @@ export class TaskCreationService {
rfaRevisionId: number,
reviewTeamPublicId: string,
dueDate: Date,
manager: EntityManager,
manager: EntityManager
): Promise<ReviewTask[]> {
// ดึง ReviewTeam พร้อม members
const team = await this.reviewTeamRepo.findOne({
@@ -46,7 +49,7 @@ export class TaskCreationService {
if (!team || !team.isActive) {
this.logger.warn(
`ReviewTeam ${reviewTeamPublicId} not found or inactive — skipping task creation`,
`ReviewTeam ${reviewTeamPublicId} not found or inactive — skipping task creation`
);
return [];
}
@@ -55,7 +58,7 @@ export class TaskCreationService {
if (members.length === 0) {
this.logger.warn(
`ReviewTeam ${reviewTeamPublicId} has no members — skipping task creation`,
`ReviewTeam ${reviewTeamPublicId} has no members — skipping task creation`
);
return [];
}
@@ -65,7 +68,7 @@ export class TaskCreationService {
for (const member of members) {
// LEAD มี priority สูงสุด ถ้ามีหลายคนใน Discipline เดียวกัน
const existing = disciplineMap.get(member.disciplineId);
if (!existing || member.role === 'LEAD') {
if (!existing || member.role === ReviewTeamMemberRole.LEAD) {
disciplineMap.set(member.disciplineId, member);
}
}
@@ -87,7 +90,7 @@ export class TaskCreationService {
}
this.logger.log(
`Created ${tasks.length} parallel review tasks for RFA revision ${rfaRevisionId}, team ${reviewTeamPublicId}`,
`Created ${tasks.length} parallel review tasks for RFA revision ${rfaRevisionId}, team ${reviewTeamPublicId}`
);
return tasks;
@@ -106,7 +109,7 @@ export class TaskCreationService {
return tasks.every(
(t: ReviewTask) =>
t.status === ReviewTaskStatus.COMPLETED ||
t.status === ReviewTaskStatus.CANCELLED,
t.status === ReviewTaskStatus.CANCELLED
);
}
}
@@ -1,11 +1,16 @@
// File: src/modules/review-team/services/veto-override.service.ts
// PM Veto Override — บังคับผ่าน RFA Revision แม้มี Code 3 (T068.5)
import { Injectable, Logger, ForbiddenException, NotFoundException } from '@nestjs/common';
import {
Injectable,
Logger,
ForbiddenException,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';
import { ReviewTask } from '../entities/review-task.entity';
import { ApprovalListenerService } from '../../distribution/services/approval-listener.service';
import { ConsensusDecision, ReviewTaskStatus } from '../../common/enums/review.enums';
import { ConsensusDecision } from '../../common/enums/review.enums';
export interface VetoOverrideDto {
rfaRevisionId: number;
@@ -25,34 +30,42 @@ export class VetoOverrideService {
@InjectRepository(ReviewTask)
private readonly taskRepo: Repository<ReviewTask>,
private readonly approvalListenerService: ApprovalListenerService,
private readonly dataSource: DataSource,
private readonly dataSource: DataSource
) {}
/**
* PM Override: บังคับ APPROVED แม้ว่าจะมี Code 3 rejection (FR-012)
* ต้องมี justification reason และ audit trail
*/
async executeOverride(dto: VetoOverrideDto): Promise<{ decision: ConsensusDecision }> {
async executeOverride(
dto: VetoOverrideDto
): Promise<{ decision: ConsensusDecision }> {
const tasks = await this.taskRepo.find({
where: { rfaRevisionId: dto.rfaRevisionId },
relations: ['responseCode'],
});
if (tasks.length === 0) {
throw new NotFoundException(`No review tasks found for revision ${dto.rfaRevisionId}`);
throw new NotFoundException(
`No review tasks found for revision ${dto.rfaRevisionId}`
);
}
const hasVeto = tasks.some((t) => t.responseCode?.code === '3');
if (!hasVeto) {
throw new ForbiddenException('No Code 3 veto found — override not needed');
throw new ForbiddenException(
'No Code 3 veto found — override not needed'
);
}
if (!dto.overrideReason || dto.overrideReason.trim().length < 10) {
throw new ForbiddenException('Override reason must be at least 10 characters');
throw new ForbiddenException(
'Override reason must be at least 10 characters'
);
}
this.logger.warn(
`PM Override executed by user ${dto.overriddenByUserId} for revision ${dto.rfaRevisionId}. Reason: ${dto.overrideReason}`,
`PM Override executed by user ${dto.overriddenByUserId} for revision ${dto.rfaRevisionId}. Reason: ${dto.overrideReason}`
);
await this.approvalListenerService.onConsensusReached({