feat(rfa): complete RFA Approval Refactor - all 9 phases (T001-T080)

Phase 1-2: Setup, SQL schema, enums, queue constants, base entities
Phase 3 (US1): ReviewTeam, ReviewTeamMember, ReviewTask, TaskCreationService
Phase 4 (US2): ResponseCode, ResponseCodeRule, ImplicationsService, NotificationTriggerService
Phase 5 (US3): Delegation entity, CircularDetectionService, DelegationService/Controller/Module
Phase 6 (US4): ReminderRule, SchedulerService, EscalationService, ReminderProcessor, ReminderModule
Phase 7 (US5): DistributionMatrix, DistributionRecipient, ApprovalListenerService (Strangler),
               TransmittalCreatorService, DistributionProcessor, DistributionModule
Phase 8 (US6): MatrixManagementService, InheritanceService (global→project override)
Phase 9 (Polish): AggregateStatusService, ConsensusService, VetoOverrideService,
                  ParallelGatewayHandler, review-validators, optimistic locking in completeReview,
                  test stubs (unit/integration/e2e), jest.config.js updated for tests/ directory

Frontend: ReviewTaskInbox, ParallelProgress, VetoOverrideDialog, DelegationForm,
          DelegatedBadge, MatrixEditor, ProjectOverrideManager, DistributionStatus,
          ReminderHistory, ResponseCodeSelector, CodeImplications, CompleteReviewForm,
          ReviewTeamForm, ReviewTeamSelector, TeamMemberManager

Closes #1
This commit is contained in:
Nattanin
2026-05-12 16:17:27 +07:00
parent 3df8707b7f
commit ef20839f99
82 changed files with 7052 additions and 104 deletions
@@ -0,0 +1,40 @@
// File: src/modules/delegation/delegation.controller.ts
import { Controller, Get, Post, Delete, Body, Param, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { User } from '../user/entities/user.entity';
import { DelegationService } from './delegation.service';
import { CreateDelegationDto } from './dto/create-delegation.dto';
@Controller('delegations')
@UseGuards(JwtAuthGuard)
export class DelegationController {
constructor(private readonly delegationService: DelegationService) {}
/**
* GET /delegations
* ดึง Delegations ของ User ที่ login อยู่
*/
@Get()
findMyDelegations(@CurrentUser() user: User) {
return this.delegationService.findByDelegator(user.publicId);
}
/**
* POST /delegations
* สร้าง Delegation ใหม่ (FR-011)
*/
@Post()
create(@CurrentUser() user: User, @Body() dto: CreateDelegationDto) { // eslint-disable-line @typescript-eslint/no-unused-vars
return this.delegationService.create(user.publicId, dto);
}
/**
* DELETE /delegations/:publicId
* Revoke delegation
*/
@Delete(':publicId')
revoke(@Param('publicId') publicId: string, @CurrentUser() user: User) {
return this.delegationService.revoke(publicId, user.publicId);
}
}
@@ -0,0 +1,20 @@
// File: src/modules/delegation/delegation.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Delegation } from './entities/delegation.entity';
import { User } from '../user/entities/user.entity';
import { DelegationService } from './delegation.service';
import { DelegationController } from './delegation.controller';
import { CircularDetectionService } from './services/circular-detection.service';
import { UserModule } from '../user/user.module';
@Module({
imports: [
TypeOrmModule.forFeature([Delegation, User]),
UserModule,
],
providers: [DelegationService, CircularDetectionService],
controllers: [DelegationController],
exports: [DelegationService],
})
export class DelegationModule {}
@@ -0,0 +1,115 @@
// File: src/modules/delegation/delegation.service.ts
import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Delegation } from './entities/delegation.entity';
import { User } from '../user/entities/user.entity';
import { CircularDetectionService } from './services/circular-detection.service';
import { CreateDelegationDto } from './dto/create-delegation.dto';
@Injectable()
export class DelegationService {
private readonly logger = new Logger(DelegationService.name);
constructor(
@InjectRepository(Delegation)
private readonly delegationRepo: Repository<Delegation>,
@InjectRepository(User)
private readonly userRepo: Repository<User>,
private readonly circularDetectionService: CircularDetectionService,
) {}
/**
* สร้าง Delegation ใหม่ พร้อมตรวจสอบ Circular (FR-011, FR-012)
*/
async create(delegatorPublicId: string, dto: CreateDelegationDto): Promise<Delegation> {
const delegator = await this.userRepo.findOne({ where: { publicId: delegatorPublicId } });
if (!delegator) throw new NotFoundException(`User not found: ${delegatorPublicId}`);
const delegate = await this.userRepo.findOne({ where: { publicId: dto.delegateUserPublicId } });
if (!delegate) throw new NotFoundException(`Delegate user not found: ${dto.delegateUserPublicId}`);
// ตรวจสอบ date range
if (dto.startDate >= dto.endDate) {
throw new BadRequestException('startDate must be before endDate');
}
// ตรวจสอบ Circular Delegation (ADR requirement)
const isCircular = await this.circularDetectionService.wouldCreateCircle(
delegator.user_id,
delegate.user_id,
dto.startDate,
);
if (isCircular) {
throw new BadRequestException(
'Circular delegation detected — this would create a delegation loop',
);
}
const delegation = this.delegationRepo.create({
delegatorUserId: delegator.user_id,
delegateUserId: delegate.user_id,
scope: dto.scope,
startDate: dto.startDate,
endDate: dto.endDate,
reason: dto.reason,
isActive: true,
});
return this.delegationRepo.save(delegation);
}
/**
* ดึง Delegations ของ User ทั้งหมด (ในฐานะผู้มอบหมาย)
*/
async findByDelegator(delegatorPublicId: string): Promise<Delegation[]> {
const user = await this.userRepo.findOne({ where: { publicId: delegatorPublicId } });
if (!user) throw new NotFoundException(delegatorPublicId);
return this.delegationRepo.find({
where: { delegatorUserId: user.user_id },
relations: ['delegate'],
order: { startDate: 'DESC' },
});
}
/**
* ดึง Active Delegations สำหรับ User ณ วันที่กำหนด (FR-013)
* ใช้ใน ReviewTaskService ก่อน assign task
*/
async findActiveDelegate(userId: number, date: Date = new Date()): Promise<User | null> {
const delegation = await this.delegationRepo
.createQueryBuilder('d')
.innerJoinAndSelect('d.delegate', 'delegate')
.where('d.delegator_user_id = :userId', { userId })
.andWhere('d.is_active = 1')
.andWhere('d.start_date <= :date', { date })
.andWhere('d.end_date >= :date', { date })
.orderBy('d.created_at', 'DESC')
.getOne();
return delegation?.delegate ?? null;
}
/**
* Revoke delegation ก่อนกำหนด
*/
async revoke(publicId: string, delegatorPublicId: string): Promise<void> {
const delegation = await this.delegationRepo.findOne({
where: { publicId },
});
if (!delegation) throw new NotFoundException(`Delegation not found: ${publicId}`);
// ตรวจสอบ ownership
const delegator = await this.userRepo.findOne({ where: { publicId: delegatorPublicId } });
if (!delegator || delegation.delegatorUserId !== delegator.user_id) {
throw new BadRequestException('You can only revoke your own delegations');
}
delegation.isActive = false;
delegation.endDate = new Date(); // หยุดทันที
await this.delegationRepo.save(delegation);
}
}
@@ -0,0 +1,31 @@
// File: src/modules/delegation/dto/create-delegation.dto.ts
import { IsDate, IsEnum, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
import { Type } from 'class-transformer';
import { DelegationScope } from '../../common/enums/review.enums';
export { DelegationScope };
export class CreateDelegationDto {
@IsUUID()
delegateUserPublicId!: string;
@IsEnum(DelegationScope)
scope!: DelegationScope;
@IsOptional()
@IsUUID()
projectPublicId?: string;
@IsDate()
@Type(() => Date)
startDate!: Date;
@IsDate()
@Type(() => Date)
endDate!: Date;
@IsOptional()
@IsString()
@MaxLength(500)
reason?: string;
}
@@ -0,0 +1,67 @@
// File: src/modules/delegation/entities/delegation.entity.ts
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Exclude } from 'class-transformer';
import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity';
import { User } from '../../user/entities/user.entity';
import { DelegationScope } from '../../common/enums/review.enums';
@Entity('delegations')
export class Delegation extends UuidBaseEntity {
@PrimaryGeneratedColumn()
@Exclude()
id!: number;
@Column({ name: 'delegator_user_id' })
@Exclude()
delegatorUserId!: number; // ผู้มอบหมาย (A)
@Column({ name: 'delegate_user_id' })
@Exclude()
delegateUserId!: number; // ผู้รับมอบหมาย (B)
@Column({
type: 'enum',
enum: DelegationScope,
default: DelegationScope.ALL,
})
scope!: DelegationScope;
@Column({ name: 'project_id', nullable: true })
@Exclude()
projectId?: number; // NULL = all projects (ถ้า scope = PROJECT)
@Column({ name: 'start_date', type: 'date' })
startDate!: Date;
@Column({ name: 'end_date', type: 'date' })
endDate!: Date;
@Column({ type: 'text', nullable: true })
reason?: string;
@Column({ name: 'is_active', type: 'tinyint', default: 1 })
isActive!: boolean;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date;
// Relations
@ManyToOne(() => User)
@JoinColumn({ name: 'delegator_user_id' })
delegator?: User;
@ManyToOne(() => User)
@JoinColumn({ name: 'delegate_user_id' })
delegate?: User;
}
@@ -0,0 +1,76 @@
// File: src/modules/delegation/services/circular-detection.service.ts
// ตรวจจับ Circular Delegation (A→B→C→A) ป้องกัน infinite loop (FR-012)
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Delegation } from '../entities/delegation.entity';
@Injectable()
export class CircularDetectionService {
constructor(
@InjectRepository(Delegation)
private readonly delegationRepo: Repository<Delegation>,
) {}
/**
* ตรวจสอบ Circular Delegation ด้วย Depth-First Search
* ตัวอย่าง: A→B→C→A จะถูกจับได้เมื่อ proposedFrom=A, proposedTo=B
*
* @param proposedFrom - delegatorUserId ที่กำลังจะสร้าง delegation
* @param proposedTo - delegateUserId ที่กำลังจะสร้าง delegation
* @param today - วันที่ตรวจสอบ (default: now)
* @returns true ถ้าจะเกิด circular delegation
*/
async wouldCreateCircle(
proposedFrom: number,
proposedTo: number,
today: Date = new Date(),
): Promise<boolean> {
// ถ้า A→B และ proposedFrom=B, proposedTo=A → circular ชัดเจน
if (proposedFrom === proposedTo) return true;
// ดึง delegations ที่ active ทั้งหมดในช่วงเวลานั้น
const activeDelegations = await this.delegationRepo
.createQueryBuilder('d')
.where('d.is_active = 1')
.andWhere('d.start_date <= :today', { today })
.andWhere('d.end_date >= :today', { today })
.select(['d.delegatorUserId', 'd.delegateUserId'])
.getMany();
// สร้าง adjacency list: from → [to, ...]
const graph = new Map<number, number[]>();
for (const d of activeDelegations) {
if (!graph.has(d.delegatorUserId)) graph.set(d.delegatorUserId, []);
graph.get(d.delegatorUserId)!.push(d.delegateUserId);
}
// เพิ่ม edge ที่กำลังจะสร้าง
if (!graph.has(proposedFrom)) graph.set(proposedFrom, []);
graph.get(proposedFrom)!.push(proposedTo);
// DFS จาก proposedTo เพื่อหา path กลับมาที่ proposedFrom
return this.dfsHasCycle(proposedTo, proposedFrom, graph, new Set());
}
private dfsHasCycle(
current: number,
target: number,
graph: Map<number, number[]>,
visited: Set<number>,
): boolean {
if (current === target) return true;
if (visited.has(current)) return false;
visited.add(current);
const neighbors = graph.get(current) ?? [];
for (const neighbor of neighbors) {
if (this.dfsHasCycle(neighbor, target, graph, visited)) {
return true;
}
}
return false;
}
}