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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user