diff --git a/backend/jest.config.js b/backend/jest.config.js index e80b6927..a4c5a814 100644 --- a/backend/jest.config.js +++ b/backend/jest.config.js @@ -14,10 +14,14 @@ module.exports = { moduleFileExtensions: ['js', 'json', 'ts'], // Root directory for tests - rootDir: 'src', + rootDir: '.', - // Test file pattern - testRegex: '.*\\.spec\\.ts$', + // Test file pattern — ครอบคลุมทั้ง src/ (unit) และ tests/ (integration/e2e) + testMatch: [ + '/src/**/*.spec.ts', + '/tests/**/*.spec.ts', + '/tests/**/*.e2e-spec.ts', + ], // TypeScript transformation transform: { @@ -30,16 +34,16 @@ module.exports = { // Coverage configuration collectCoverageFrom: [ - '**/*.(t|j)s', - '!**/*.d.ts', - '!**/index.ts', - '!**/database/seeds/**', - '!**/database/migrations/**', - '!**/config/**', - '!**/scripts/**', - '!**/*.module.ts', + 'src/**/*.(t|j)s', + '!src/**/*.d.ts', + '!src/**/index.ts', + '!src/**/database/seeds/**', + '!src/**/database/migrations/**', + '!src/**/config/**', + '!src/**/scripts/**', + '!src/**/*.module.ts', ], - coverageDirectory: '../coverage', + coverageDirectory: './coverage', coveragePathIgnorePatterns: ['/node_modules/', '/test/', '/dist/'], // Test environment @@ -49,7 +53,7 @@ module.exports = { cacheDirectory: '.jest-cache', // Global setup after env - setupFilesAfterEnv: ['../test/jest.setup.ts'], + setupFilesAfterEnv: ['./test/jest.setup.ts'], // Transform ignore patterns (ให้ Jest ประมวลผล ESM modules) // รองรับ uuid และ @nestjs/elasticsearch ที่เป็น ESM @@ -100,11 +104,11 @@ module.exports = { // Module name mapper for path aliases moduleNameMapper: { - '^@/(.*)$': '/$1', - '^@common/(.*)$': '/common/$1', - '^@modules/(.*)$': '/modules/$1', - '^@config/(.*)$': '/config/$1', - '^@database/(.*)$': '/database/$1', + '^@/(.*)$': '/src/$1', + '^@common/(.*)$': '/src/common/$1', + '^@modules/(.*)$': '/src/modules/$1', + '^@config/(.*)$': '/src/config/$1', + '^@database/(.*)$': '/src/database/$1', }, // Verbose output for debugging diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 12fdc450..b263af9f 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -53,6 +53,11 @@ import { AuditLogModule } from './modules/audit-log/audit-log.module'; import { MigrationModule } from './modules/migration/migration.module'; import { AiModule } from './modules/ai/ai.module'; import { RagModule } from './modules/rag/rag.module'; +import { ReviewTeamModule } from './modules/review-team/review-team.module'; +import { ResponseCodeModule } from './modules/response-code/response-code.module'; +import { DelegationModule } from './modules/delegation/delegation.module'; +import { ReminderModule } from './modules/reminder/reminder.module'; +import { DistributionModule } from './modules/distribution/distribution.module'; @Module({ imports: [ @@ -191,6 +196,11 @@ import { RagModule } from './modules/rag/rag.module'; MigrationModule, AiModule, RagModule, + ReviewTeamModule, + ResponseCodeModule, + DelegationModule, + ReminderModule, + DistributionModule, ], controllers: [AppController], providers: [ diff --git a/backend/src/common/validators/review-validators.ts b/backend/src/common/validators/review-validators.ts new file mode 100644 index 00000000..a7c0dddf --- /dev/null +++ b/backend/src/common/validators/review-validators.ts @@ -0,0 +1,73 @@ +// File: src/common/validators/review-validators.ts +// Edge case validators สำหรับ RFA Review workflow (T073) + +/** + * ตรวจสอบว่า due date ถูกต้อง (ต้องอยู่ในอนาคต) + */ +export function validateDueDate(dueDate: Date): void { + const now = new Date(); + if (dueDate <= now) { + throw new Error('Due date must be in the future'); + } +} + +/** + * ตรวจสอบ delegation date range ไม่เกิน 90 วัน + */ +export function validateDelegationDateRange(startDate: Date, endDate: Date): void { + if (endDate <= startDate) { + throw new Error('End date must be after start date'); + } + + const maxDays = 90; + const diffMs = endDate.getTime() - startDate.getTime(); + const diffDays = Math.floor(diffMs / 86_400_000); + + if (diffDays > maxDays) { + throw new Error(`Delegation period cannot exceed ${maxDays} days`); + } +} + +/** + * ตรวจสอบ ReviewTask ว่าสามารถ complete ได้ (ต้องมี response code) + */ +export function validateTaskCompletionRequirements( + taskStatus: string, + responseCodeId: number | undefined | null, + requiresComments: boolean, + comments: string | undefined | null, +): void { + if (taskStatus === 'COMPLETED') { + if (!responseCodeId) { + throw new Error('Response code is required to complete a review task'); + } + + if (requiresComments && (!comments || comments.trim().length === 0)) { + throw new Error('Comments are required for this response code'); + } + } +} + +/** + * ตรวจสอบ version สำหรับ optimistic locking (ADR-002) + */ +export function validateVersion( + expectedVersion: number, + actualVersion: number, + entityName: string, +): void { + if (actualVersion !== expectedVersion) { + throw new Error( + `Optimistic lock conflict on ${entityName}: expected version ${expectedVersion}, got ${actualVersion}. Please retry.`, + ); + } +} + +/** + * ตรวจสอบว่า override reason มีความยาวเพียงพอ + */ +export function validateOverrideReason(reason: string, minLength = 10): void { + if (!reason || reason.trim().length < minLength) { + throw new Error(`Override reason must be at least ${minLength} characters`); + } +} diff --git a/backend/src/modules/common/constants/queue.constants.ts b/backend/src/modules/common/constants/queue.constants.ts new file mode 100644 index 00000000..3b9a2851 --- /dev/null +++ b/backend/src/modules/common/constants/queue.constants.ts @@ -0,0 +1,18 @@ +// File: src/modules/common/constants/queue.constants.ts +// Queue name constants สำหรับ BullMQ (ADR-008) +// รวม queue ทั้งหมดของระบบไว้ที่เดียว + +// ─── Existing Queues ─────────────────────────────────────────────────────── +export const QUEUE_NOTIFICATIONS = 'notifications'; +export const QUEUE_WORKFLOW_EVENTS = 'workflow-events'; + +// ─── New Queues (Feature: 1-rfa-approval-refactor) ──────────────────────── + +/** Queue สำหรับ Auto-Reminders และ Escalation (T043-T047) */ +export const QUEUE_REMINDERS = 'reminders'; + +/** Queue สำหรับ Distribution Matrix — กระจายเอกสารหลังอนุมัติ (T054-T056) */ +export const QUEUE_DISTRIBUTION = 'distribution'; + +/** Queue สำหรับ Veto Override Notifications (T068.5) */ +export const QUEUE_VETO_NOTIFICATIONS = 'veto-notifications'; diff --git a/backend/src/modules/common/enums/review.enums.ts b/backend/src/modules/common/enums/review.enums.ts new file mode 100644 index 00000000..3ae8a7d6 --- /dev/null +++ b/backend/src/modules/common/enums/review.enums.ts @@ -0,0 +1,69 @@ +// File: src/modules/common/enums/review.enums.ts +// Shared enums สำหรับ RFA Approval Refactor (Feature: 1-rfa-approval-refactor) + +// ─── Review Task Status ──────────────────────────────────────────────────── +export enum ReviewTaskStatus { + PENDING = 'PENDING', // รอดำเนินการ + IN_PROGRESS = 'IN_PROGRESS', // กำลังตรวจสอบ + COMPLETED = 'COMPLETED', // เสร็จสิ้น (มีผลลัพธ์) + DELEGATED = 'DELEGATED', // ถูกมอบหมายให้ผู้อื่น + EXPIRED = 'EXPIRED', // เกินกำหนด + CANCELLED = 'CANCELLED', // ยกเลิก +} + +// ─── Response Code Category ──────────────────────────────────────────────── +export enum ResponseCodeCategory { + ENGINEERING = 'ENGINEERING', // Shop Drawing / Method Statement / As-Built + MATERIAL = 'MATERIAL', // Material / Procurement Submittal + CONTRACT = 'CONTRACT', // Contract / Cost / BOQ + TESTING = 'TESTING', // Testing / Handover / QA + ESG = 'ESG', // Environment / Social / Governance +} + +// ─── Delegation Scope ────────────────────────────────────────────────────── +export enum DelegationScope { + ALL = 'ALL', // มอบหมายทุกงาน + RFA_ONLY = 'RFA_ONLY', // เฉพาะงาน RFA + CORRESPONDENCE_ONLY = 'CORRESPONDENCE_ONLY', // เฉพาะงาน Correspondence + SPECIFIC_TYPES = 'SPECIFIC_TYPES', // กำหนดประเภทเอกสารเอง +} + +// ─── Reminder Type ───────────────────────────────────────────────────────── +export enum ReminderType { + DUE_SOON = 'DUE_SOON', // X วันก่อนครบกำหนด + ON_DUE = 'ON_DUE', // วันครบกำหนด + OVERDUE = 'OVERDUE', // หลังครบกำหนด (ส่งซ้ำทุกวัน) + ESCALATION_L1 = 'ESCALATION_L1', // Escalation ระดับ 1 (ถึง Manager) + ESCALATION_L2 = 'ESCALATION_L2', // Escalation ระดับ 2 (ถึง PM/Director) +} + +// ─── Review Team Member Role ─────────────────────────────────────────────── +export enum ReviewTeamMemberRole { + REVIEWER = 'REVIEWER', // ผู้ตรวจสอบ + LEAD = 'LEAD', // หัวหน้าทีม (Lead Reviewer) + MANAGER = 'MANAGER', // ผู้จัดการ (Escalation target) +} + +// ─── Distribution Recipient Type ────────────────────────────────────────── +export enum RecipientType { + USER = 'USER', // ผู้ใช้เฉพาะคน + ORGANIZATION = 'ORGANIZATION', // องค์กร + TEAM = 'TEAM', // ทีม + ROLE = 'ROLE', // บทบาท เช่น ALL_QS, ALL_SITE_ENG +} + +// ─── Distribution Delivery Method ───────────────────────────────────────── +export enum DeliveryMethod { + EMAIL = 'EMAIL', // ส่งอีเมล + IN_APP = 'IN_APP', // แจ้งเตือนในระบบ + BOTH = 'BOTH', // ทั้งสองช่องทาง +} + +// ─── Consensus Decision (Parallel Review) ───────────────────────────────── +export enum ConsensusDecision { + APPROVED = 'APPROVED', // ผ่าน (Majority approved) + REJECTED = 'REJECTED', // ไม่ผ่าน (Veto triggered by Code 3) + APPROVED_WITH_COMMENTS = 'APPROVED_WITH_COMMENTS', // ผ่านพร้อมหมายเหตุ + PENDING = 'PENDING', // รอผล (ยังไม่ครบทุก Discipline) + OVERRIDDEN = 'OVERRIDDEN', // PM Override — บังคับผ่าน +} diff --git a/backend/src/modules/delegation/delegation.controller.ts b/backend/src/modules/delegation/delegation.controller.ts new file mode 100644 index 00000000..3d34d862 --- /dev/null +++ b/backend/src/modules/delegation/delegation.controller.ts @@ -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); + } +} diff --git a/backend/src/modules/delegation/delegation.module.ts b/backend/src/modules/delegation/delegation.module.ts new file mode 100644 index 00000000..1ea1feda --- /dev/null +++ b/backend/src/modules/delegation/delegation.module.ts @@ -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 {} diff --git a/backend/src/modules/delegation/delegation.service.ts b/backend/src/modules/delegation/delegation.service.ts new file mode 100644 index 00000000..4b0cc86a --- /dev/null +++ b/backend/src/modules/delegation/delegation.service.ts @@ -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, + @InjectRepository(User) + private readonly userRepo: Repository, + private readonly circularDetectionService: CircularDetectionService, + ) {} + + /** + * สร้าง Delegation ใหม่ พร้อมตรวจสอบ Circular (FR-011, FR-012) + */ + async create(delegatorPublicId: string, dto: CreateDelegationDto): Promise { + 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 { + 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 { + 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 { + 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); + } +} diff --git a/backend/src/modules/delegation/dto/create-delegation.dto.ts b/backend/src/modules/delegation/dto/create-delegation.dto.ts new file mode 100644 index 00000000..5a91b641 --- /dev/null +++ b/backend/src/modules/delegation/dto/create-delegation.dto.ts @@ -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; +} diff --git a/backend/src/modules/delegation/entities/delegation.entity.ts b/backend/src/modules/delegation/entities/delegation.entity.ts new file mode 100644 index 00000000..e7868ae0 --- /dev/null +++ b/backend/src/modules/delegation/entities/delegation.entity.ts @@ -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; +} diff --git a/backend/src/modules/delegation/services/circular-detection.service.ts b/backend/src/modules/delegation/services/circular-detection.service.ts new file mode 100644 index 00000000..9b3ffb4e --- /dev/null +++ b/backend/src/modules/delegation/services/circular-detection.service.ts @@ -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, + ) {} + + /** + * ตรวจสอบ 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 { + // ถ้า 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(); + 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, + visited: Set, + ): 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; + } +} diff --git a/backend/src/modules/distribution/distribution-matrix.service.ts b/backend/src/modules/distribution/distribution-matrix.service.ts new file mode 100644 index 00000000..98c763db --- /dev/null +++ b/backend/src/modules/distribution/distribution-matrix.service.ts @@ -0,0 +1,84 @@ +// File: src/modules/distribution/distribution-matrix.service.ts +// CRUD สำหรับ DistributionMatrix และ Recipients (T053) +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { DistributionMatrix } from './entities/distribution-matrix.entity'; +import { DistributionRecipient } from './entities/distribution-recipient.entity'; + +export interface CreateDistributionMatrixDto { + projectId: number; + documentTypeCode: string; + responseCodeFilter?: string[]; +} + +export interface AddRecipientDto { + recipientType: string; + recipientId?: number; + roleCode?: string; + deliveryMethod?: string; + isCc?: boolean; +} + +@Injectable() +export class DistributionMatrixService { + private readonly logger = new Logger(DistributionMatrixService.name); + + constructor( + @InjectRepository(DistributionMatrix) + private readonly matrixRepo: Repository, + @InjectRepository(DistributionRecipient) + private readonly recipientRepo: Repository, + ) {} + + async findByProject(projectId: number): Promise { + return this.matrixRepo.find({ + where: { projectId, isActive: true }, + relations: ['recipients'], + order: { documentTypeCode: 'ASC' }, + }); + } + + async findOneByDocType( + projectId: number, + documentTypeCode: string, + ): Promise { + return this.matrixRepo.findOne({ + where: { projectId, documentTypeCode, isActive: true }, + relations: ['recipients'], + }); + } + + async create(dto: CreateDistributionMatrixDto): Promise { + const matrix = this.matrixRepo.create(dto as Partial); + return this.matrixRepo.save(matrix); + } + + async addRecipient( + matrixPublicId: string, + dto: AddRecipientDto, + ): Promise { + const matrix = await this.matrixRepo.findOne({ where: { publicId: matrixPublicId } }); + if (!matrix) throw new NotFoundException(`Matrix not found: ${matrixPublicId}`); + + const recipient = this.recipientRepo.create({ + matrixId: matrix.id, + ...dto, + } as Partial); + + return this.recipientRepo.save(recipient); + } + + async removeRecipient(recipientPublicId: string): Promise { + const recipient = await this.recipientRepo.findOne({ where: { publicId: recipientPublicId } }); + if (!recipient) throw new NotFoundException(recipientPublicId); + await this.recipientRepo.remove(recipient); + } + + async remove(publicId: string): Promise { + const matrix = await this.matrixRepo.findOne({ where: { publicId } }); + if (!matrix) throw new NotFoundException(publicId); + matrix.isActive = false; + await this.matrixRepo.save(matrix); + } +} diff --git a/backend/src/modules/distribution/distribution.controller.ts b/backend/src/modules/distribution/distribution.controller.ts new file mode 100644 index 00000000..17ee9b64 --- /dev/null +++ b/backend/src/modules/distribution/distribution.controller.ts @@ -0,0 +1,50 @@ +// File: src/modules/distribution/distribution.controller.ts +// Admin endpoints สำหรับจัดการ Distribution Matrix (T058) +import { Controller, Get, Post, Delete, Body, Param, Query, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { DistributionMatrixService } from './distribution-matrix.service'; + +class CreateMatrixDto { + projectId!: number; + documentTypeCode!: string; + responseCodeFilter?: string[]; +} + +class AddRecipientDto { + recipientType!: string; + recipientId?: number; + roleCode?: string; + deliveryMethod?: string; + isCc?: boolean; +} + +@Controller('admin/distribution-matrices') +@UseGuards(JwtAuthGuard) +export class DistributionController { + constructor(private readonly matrixService: DistributionMatrixService) {} + + @Get() + findByProject(@Query('projectId') projectId: string) { + return this.matrixService.findByProject(parseInt(projectId, 10)); + } + + @Post() + create(@Body() dto: CreateMatrixDto) { + return this.matrixService.create(dto); + } + + @Post(':publicId/recipients') + addRecipient(@Param('publicId') publicId: string, @Body() dto: AddRecipientDto) { + return this.matrixService.addRecipient(publicId, dto); + } + + @Delete(':publicId/recipients/:recipientPublicId') + removeRecipient(@Param('recipientPublicId') recipientPublicId: string) { + return this.matrixService.removeRecipient(recipientPublicId); + } + + @Delete(':publicId') + remove(@Param('publicId') publicId: string) { + return this.matrixService.remove(publicId); + } +} diff --git a/backend/src/modules/distribution/distribution.module.ts b/backend/src/modules/distribution/distribution.module.ts new file mode 100644 index 00000000..e3f6e349 --- /dev/null +++ b/backend/src/modules/distribution/distribution.module.ts @@ -0,0 +1,32 @@ +// File: src/modules/distribution/distribution.module.ts +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { BullModule } from '@nestjs/bullmq'; +import { DistributionMatrix } from './entities/distribution-matrix.entity'; +import { DistributionRecipient } from './entities/distribution-recipient.entity'; +import { DistributionService } from './distribution.service'; +import { DistributionMatrixService } from './distribution-matrix.service'; +import { DistributionController } from './distribution.controller'; +import { DistributionProcessor } from './processors/distribution.processor'; +import { ApprovalListenerService } from './services/approval-listener.service'; +import { TransmittalCreatorService } from './services/transmittal-creator.service'; +import { QUEUE_DISTRIBUTION } from '../common/constants/queue.constants'; +import { NotificationModule } from '../notification/notification.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([DistributionMatrix, DistributionRecipient]), + BullModule.registerQueue({ name: QUEUE_DISTRIBUTION }), + NotificationModule, + ], + providers: [ + DistributionService, + DistributionMatrixService, + DistributionProcessor, + ApprovalListenerService, + TransmittalCreatorService, + ], + controllers: [DistributionController], + exports: [DistributionService, DistributionMatrixService, ApprovalListenerService], +}) +export class DistributionModule {} diff --git a/backend/src/modules/distribution/distribution.service.ts b/backend/src/modules/distribution/distribution.service.ts new file mode 100644 index 00000000..4f6055d6 --- /dev/null +++ b/backend/src/modules/distribution/distribution.service.ts @@ -0,0 +1,53 @@ +// File: src/modules/distribution/distribution.service.ts +// Enqueue distribution jobs เมื่อ RFA ได้รับการอนุมัติ (T054) +import { Injectable, Logger } from '@nestjs/common'; +import { InjectQueue } from '@nestjs/bullmq'; +import { Queue } from 'bullmq'; +import { QUEUE_DISTRIBUTION } from '../common/constants/queue.constants'; + +export interface DistributionJobPayload { + rfaPublicId: string; + rfaRevisionPublicId: string; + projectId: number; + documentTypeCode: string; + responseCode: string; + approvedAt: Date; +} + +@Injectable() +export class DistributionService { + private readonly logger = new Logger(DistributionService.name); + + constructor( + @InjectQueue(QUEUE_DISTRIBUTION) + private readonly distributionQueue: Queue, + ) {} + + /** + * Queue distribution job สำหรับ RFA ที่ผ่านการอนุมัติ (FR-018) + */ + async queueDistribution(payload: DistributionJobPayload): Promise { + await this.distributionQueue.add('process-distribution', payload, { + removeOnComplete: true, + removeOnFail: 100, + attempts: 3, + backoff: { type: 'exponential', delay: 5000 }, + }); + + this.logger.log( + `Distribution queued for RFA ${payload.rfaPublicId} (code: ${payload.responseCode})`, + ); + } + + /** + * ตรวจสอบสถานะ distribution jobs ของ RFA + */ + async getJobStatus(rfaPublicId: string): Promise<{ pending: number; completed: number }> { + const [waiting, active] = await Promise.all([ + this.distributionQueue.getWaitingCount(), + this.distributionQueue.getActiveCount(), + ]); + + return { pending: waiting + active, completed: 0 }; + } +} diff --git a/backend/src/modules/distribution/entities/distribution-matrix.entity.ts b/backend/src/modules/distribution/entities/distribution-matrix.entity.ts new file mode 100644 index 00000000..f5996c41 --- /dev/null +++ b/backend/src/modules/distribution/entities/distribution-matrix.entity.ts @@ -0,0 +1,49 @@ +// File: src/modules/distribution/entities/distribution-matrix.entity.ts +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + OneToMany, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Exclude } from 'class-transformer'; +import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity'; +import { DistributionRecipient } from './distribution-recipient.entity'; +import { Project } from '../../project/entities/project.entity'; + +@Entity('distribution_matrices') +export class DistributionMatrix extends UuidBaseEntity { + @PrimaryGeneratedColumn() + @Exclude() + id!: number; + + @Column({ name: 'project_id' }) + @Exclude() + projectId!: number; + + @Column({ name: 'document_type_code', length: 20 }) + documentTypeCode!: string; // 'SDW', 'DDW', 'ADW', 'MS'... + + @Column({ name: 'response_code_filter', type: 'simple-array', nullable: true }) + responseCodeFilter?: string[]; // ['1A','1B'] — NULL = ทุก code + + @Column({ name: 'is_active', type: 'tinyint', default: 1 }) + isActive!: boolean; + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt!: Date; + + // Relations + @ManyToOne(() => Project) + @JoinColumn({ name: 'project_id' }) + project?: Project; + + @OneToMany(() => DistributionRecipient, (r: DistributionRecipient) => r.matrix, { cascade: true }) + recipients?: DistributionRecipient[]; +} diff --git a/backend/src/modules/distribution/entities/distribution-recipient.entity.ts b/backend/src/modules/distribution/entities/distribution-recipient.entity.ts new file mode 100644 index 00000000..183edd34 --- /dev/null +++ b/backend/src/modules/distribution/entities/distribution-recipient.entity.ts @@ -0,0 +1,55 @@ +// File: src/modules/distribution/entities/distribution-recipient.entity.ts +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Exclude } from 'class-transformer'; +import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity'; +import { DistributionMatrix } from './distribution-matrix.entity'; +import { RecipientType, DeliveryMethod } from '../../common/enums/review.enums'; + +@Entity('distribution_recipients') +export class DistributionRecipient extends UuidBaseEntity { + @PrimaryGeneratedColumn() + @Exclude() + id!: number; + + @Column({ name: 'matrix_id' }) + @Exclude() + matrixId!: number; + + @Column({ + type: 'enum', + enum: RecipientType, + }) + recipientType!: RecipientType; + + @Column({ name: 'recipient_id', nullable: true }) + @Exclude() + recipientId?: number; // userId / organizationId / teamId (FK based on type) + + @Column({ name: 'role_code', length: 50, nullable: true }) + roleCode?: string; // 'ALL_QS', 'ALL_SITE_ENG' (when type = ROLE) + + @Column({ + type: 'enum', + enum: DeliveryMethod, + default: DeliveryMethod.BOTH, + }) + deliveryMethod!: DeliveryMethod; + + @Column({ name: 'is_cc', type: 'tinyint', default: 0 }) + isCc!: boolean; // true = CC recipient, false = primary + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; + + // Relations + @ManyToOne(() => DistributionMatrix, (m: DistributionMatrix) => m.recipients, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'matrix_id' }) + matrix!: DistributionMatrix; +} diff --git a/backend/src/modules/distribution/processors/distribution.processor.ts b/backend/src/modules/distribution/processors/distribution.processor.ts new file mode 100644 index 00000000..5081a86d --- /dev/null +++ b/backend/src/modules/distribution/processors/distribution.processor.ts @@ -0,0 +1,39 @@ +// File: src/modules/distribution/processors/distribution.processor.ts +// BullMQ Worker สำหรับประมวลผล Distribution jobs (T056, ADR-008) +import { Processor, WorkerHost } from '@nestjs/bullmq'; +import { Logger } from '@nestjs/common'; +import { Job } from 'bullmq'; +import { QUEUE_DISTRIBUTION } from '../../common/constants/queue.constants'; +import { DistributionJobPayload } from '../distribution.service'; +import { TransmittalCreatorService } from '../services/transmittal-creator.service'; +import { NotificationService } from '../../notification/notification.service'; + +@Processor(QUEUE_DISTRIBUTION) +export class DistributionProcessor extends WorkerHost { + private readonly logger = new Logger(DistributionProcessor.name); + + constructor( + private readonly transmittalCreator: TransmittalCreatorService, + private readonly notificationService: NotificationService, + ) { + super(); + } + + async process(job: Job): Promise { + const payload = job.data; + + this.logger.log( + `Processing distribution for RFA ${payload.rfaPublicId} (${payload.documentTypeCode}, code ${payload.responseCode})`, + ); + + // 1. สร้าง Transmittal records + const result = await this.transmittalCreator.createFromDistribution(payload); + + this.logger.log( + `Created ${result.transmittalPublicIds.length} transmittals for RFA ${payload.rfaPublicId}`, + ); + + // 2. แจ้งเตือน submitter + this.logger.log(`Distribution complete for RFA ${payload.rfaPublicId}`); + } +} diff --git a/backend/src/modules/distribution/services/approval-listener.service.ts b/backend/src/modules/distribution/services/approval-listener.service.ts new file mode 100644 index 00000000..cab391f9 --- /dev/null +++ b/backend/src/modules/distribution/services/approval-listener.service.ts @@ -0,0 +1,56 @@ +// File: src/modules/distribution/services/approval-listener.service.ts +// Strangler Pattern — listens for RFA approval events and triggers distribution (T055) +import { Injectable, Logger } from '@nestjs/common'; +import { DistributionService, DistributionJobPayload } from '../distribution.service'; +import { ConsensusDecision } from '../../common/enums/review.enums'; + +/** + * ApprovalListenerService — ถูกเรียกจาก ReviewTaskService หลัง consensus ถูกตัดสินใจ + * ใช้ Strangler Pattern: ไม่แก้ไข rfaService.approve() โดยตรง + */ +@Injectable() +export class ApprovalListenerService { + private readonly logger = new Logger(ApprovalListenerService.name); + + constructor(private readonly distributionService: DistributionService) {} + + /** + * เรียกเมื่อ consensus ถูกตัดสินใจว่า APPROVED หรือ APPROVED_WITH_COMMENTS (FR-018) + */ + async onConsensusReached(event: { + rfaPublicId: string; + rfaRevisionPublicId: string; + projectId: number; + documentTypeCode: string; + responseCode: string; + decision: ConsensusDecision; + approvedAt: Date; + }): Promise { + const shouldDistribute = + event.decision === ConsensusDecision.APPROVED || + event.decision === ConsensusDecision.APPROVED_WITH_COMMENTS || + event.decision === ConsensusDecision.OVERRIDDEN; + + if (!shouldDistribute) { + this.logger.log( + `RFA ${event.rfaPublicId} decision = ${event.decision} — distribution skipped`, + ); + return; + } + + const payload: DistributionJobPayload = { + rfaPublicId: event.rfaPublicId, + rfaRevisionPublicId: event.rfaRevisionPublicId, + projectId: event.projectId, + documentTypeCode: event.documentTypeCode, + responseCode: event.responseCode, + approvedAt: event.approvedAt, + }; + + await this.distributionService.queueDistribution(payload); + + this.logger.log( + `Distribution triggered for RFA ${event.rfaPublicId} (${event.decision})`, + ); + } +} diff --git a/backend/src/modules/distribution/services/transmittal-creator.service.ts b/backend/src/modules/distribution/services/transmittal-creator.service.ts new file mode 100644 index 00000000..79778d89 --- /dev/null +++ b/backend/src/modules/distribution/services/transmittal-creator.service.ts @@ -0,0 +1,69 @@ +// File: src/modules/distribution/services/transmittal-creator.service.ts +// สร้าง Transmittal records จาก Distribution jobs (T057) +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { DistributionMatrix } from '../entities/distribution-matrix.entity'; + +/** + * TransmittalCreatorService — ใช้ Strangler Pattern ไม่แก้ไข TransmittalService เดิม + * สร้าง Transmittal ผ่าน existing TransmittalService หลัง distribution + */ +@Injectable() +export class TransmittalCreatorService { + private readonly logger = new Logger(TransmittalCreatorService.name); + + constructor( + @InjectRepository(DistributionMatrix) + private readonly matrixRepo: Repository, + ) {} + + /** + * สร้าง Transmittal draft จาก Distribution event (FR-019) + * Note: actual Transmittal creation ผ่าน TransmittalModule — inject ที่ DI level + */ + async createFromDistribution(payload: { + rfaPublicId: string; + rfaRevisionPublicId: string; + projectId: number; + documentTypeCode: string; + responseCode: string; + }): Promise<{ transmittalPublicIds: string[] }> { + const matrix = await this.matrixRepo.findOne({ + where: { + projectId: payload.projectId, + documentTypeCode: payload.documentTypeCode, + isActive: true, + }, + relations: ['recipients'], + }); + + if (!matrix || !matrix.recipients || matrix.recipients.length === 0) { + this.logger.log( + `No distribution matrix found for project ${payload.projectId}, docType ${payload.documentTypeCode}`, + ); + return { transmittalPublicIds: [] }; + } + + // ตรวจสอบ response code filter + if ( + matrix.responseCodeFilter && + matrix.responseCodeFilter.length > 0 && + !matrix.responseCodeFilter.includes(payload.responseCode) + ) { + this.logger.log( + `Response code ${payload.responseCode} not in filter — skipping distribution`, + ); + return { transmittalPublicIds: [] }; + } + + this.logger.log( + `Creating Transmittal for RFA ${payload.rfaPublicId} → ${matrix.recipients.length} recipients`, + ); + + // TODO: เรียก TransmittalService.create() เมื่อ integrate ใน Sprint ถัดไป + // return transmittalService.createDraft({ rfaPublicId, recipients }); + + return { transmittalPublicIds: [] }; + } +} diff --git a/backend/src/modules/reminder/dto/create-reminder-rule.dto.ts b/backend/src/modules/reminder/dto/create-reminder-rule.dto.ts new file mode 100644 index 00000000..cfe8651b --- /dev/null +++ b/backend/src/modules/reminder/dto/create-reminder-rule.dto.ts @@ -0,0 +1,29 @@ +// File: src/modules/reminder/dto/create-reminder-rule.dto.ts +import { IsEnum, IsInt, IsOptional, IsString, IsArray, MaxLength } from 'class-validator'; +import { ReminderType } from '../../common/enums/review.enums'; + +export class CreateReminderRuleDto { + @IsOptional() + @IsInt() + projectId?: number; + + @IsOptional() + @IsString() + @MaxLength(20) + documentTypeCode?: string; + + @IsEnum(ReminderType) + reminderType!: ReminderType; + + @IsInt() + daysBeforeDue!: number; + + @IsOptional() + @IsInt() + escalationLevel?: number; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + notifyRoles?: string[]; +} diff --git a/backend/src/modules/reminder/entities/reminder-rule.entity.ts b/backend/src/modules/reminder/entities/reminder-rule.entity.ts new file mode 100644 index 00000000..bd20b266 --- /dev/null +++ b/backend/src/modules/reminder/entities/reminder-rule.entity.ts @@ -0,0 +1,49 @@ +// File: src/modules/reminder/entities/reminder-rule.entity.ts +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; +import { Exclude } from 'class-transformer'; +import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity'; +import { ReminderType } from '../../common/enums/review.enums'; + +@Entity('reminder_rules') +export class ReminderRule extends UuidBaseEntity { + @PrimaryGeneratedColumn() + @Exclude() + id!: number; + + @Column({ name: 'project_id', nullable: true }) + @Exclude() + projectId?: number; // NULL = global rule + + @Column({ name: 'document_type_code', length: 20, nullable: true }) + documentTypeCode?: string; // 'SDW', 'DDW' — NULL = all types + + @Column({ + type: 'enum', + enum: ReminderType, + }) + reminderType!: ReminderType; + + @Column({ name: 'days_before_due', type: 'int' }) + daysBeforeDue!: number; // บวก = ก่อน due, ลบ = หลัง due (overdue) + + @Column({ name: 'escalation_level', type: 'tinyint', default: 0 }) + escalationLevel!: number; // 0 = reminder, 1 = escalation L1, 2 = escalation L2 + + @Column({ name: 'notify_roles', type: 'simple-array', nullable: true }) + notifyRoles?: string[]; // เช่น ['TASK_ASSIGNEE', 'TEAM_LEAD', 'PROJECT_MANAGER'] + + @Column({ name: 'is_active', type: 'tinyint', default: 1 }) + isActive!: boolean; + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt!: Date; +} diff --git a/backend/src/modules/reminder/processors/reminder.processor.ts b/backend/src/modules/reminder/processors/reminder.processor.ts new file mode 100644 index 00000000..6bfcaa27 --- /dev/null +++ b/backend/src/modules/reminder/processors/reminder.processor.ts @@ -0,0 +1,74 @@ +// File: src/modules/reminder/processors/reminder.processor.ts +// BullMQ Worker สำหรับประมวลผล Reminder jobs (ADR-008) +import { Processor, WorkerHost } from '@nestjs/bullmq'; +import { Logger } from '@nestjs/common'; +import { Job } from 'bullmq'; +import { QUEUE_REMINDERS } from '../../common/constants/queue.constants'; +import { ReminderType } from '../../common/enums/review.enums'; +import { EscalationService } from '../services/escalation.service'; +import { NotificationService } from '../../notification/notification.service'; +import { ScheduleReminderPayload } from '../services/scheduler.service'; + +@Processor(QUEUE_REMINDERS) +export class ReminderProcessor extends WorkerHost { + private readonly logger = new Logger(ReminderProcessor.name); + + constructor( + private readonly escalationService: EscalationService, + private readonly notificationService: NotificationService, + ) { + super(); + } + + async process(job: Job): Promise { + const { taskPublicId, assigneeUserId, reminderType } = job.data; + + this.logger.log(`Processing reminder job: ${reminderType} for task ${taskPublicId}`); + + switch (reminderType) { + case ReminderType.DUE_SOON: + await this.notificationService.send({ + userId: assigneeUserId, + title: '⏰ Review Task Due Soon', + message: 'Your review task is due in 2 days. Please complete your review.', + type: 'SYSTEM', + entityType: 'review_task', + entityId: taskPublicId as unknown as number, + }); + break; + + case ReminderType.ON_DUE: + await this.notificationService.send({ + userId: assigneeUserId, + title: '🔔 Review Task Due Today', + message: 'Your review task is due today. Please complete it as soon as possible.', + type: 'SYSTEM', + entityType: 'review_task', + entityId: taskPublicId as unknown as number, + }); + break; + + case ReminderType.OVERDUE: + await this.notificationService.send({ + userId: assigneeUserId, + title: '🚨 Review Task Overdue', + message: 'Your review task is overdue. Escalation will occur if not completed.', + type: 'SYSTEM', + entityType: 'review_task', + entityId: taskPublicId as unknown as number, + }); + break; + + case ReminderType.ESCALATION_L1: + await this.escalationService.escalateLevel1(taskPublicId); + break; + + case ReminderType.ESCALATION_L2: + await this.escalationService.escalateLevel2(taskPublicId); + break; + + default: + this.logger.warn(`Unknown reminder type: ${reminderType as string}`); + } + } +} diff --git a/backend/src/modules/reminder/reminder.controller.ts b/backend/src/modules/reminder/reminder.controller.ts new file mode 100644 index 00000000..8603c089 --- /dev/null +++ b/backend/src/modules/reminder/reminder.controller.ts @@ -0,0 +1,37 @@ +// File: src/modules/reminder/reminder.controller.ts +// Admin endpoints สำหรับจัดการ Reminder Rules (T048) +import { Controller, Get, Post, Patch, Delete, Body, Param, Query, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { ReminderService } from './reminder.service'; +import { CreateReminderRuleDto } from './dto/create-reminder-rule.dto'; + +@Controller('admin/reminder-rules') +@UseGuards(JwtAuthGuard) +export class ReminderController { + constructor(private readonly reminderService: ReminderService) {} + + @Get() + findAll(@Query('projectId') projectId?: string) { + return this.reminderService.findAll(projectId ? parseInt(projectId, 10) : undefined); + } + + @Get(':publicId') + findOne(@Param('publicId') publicId: string) { + return this.reminderService.findOne(publicId); + } + + @Post() + create(@Body() dto: CreateReminderRuleDto): Promise { + return this.reminderService.create(dto); + } + + @Patch(':publicId') + update(@Param('publicId') publicId: string, @Body() dto: Partial): Promise { + return this.reminderService.update(publicId, dto); + } + + @Delete(':publicId') + remove(@Param('publicId') publicId: string) { + return this.reminderService.remove(publicId); + } +} diff --git a/backend/src/modules/reminder/reminder.module.ts b/backend/src/modules/reminder/reminder.module.ts new file mode 100644 index 00000000..e529b978 --- /dev/null +++ b/backend/src/modules/reminder/reminder.module.ts @@ -0,0 +1,30 @@ +// File: src/modules/reminder/reminder.module.ts +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { BullModule } from '@nestjs/bullmq'; +import { ReminderRule } from './entities/reminder-rule.entity'; +import { ReviewTask } from '../review-team/entities/review-task.entity'; +import { ReminderService } from './reminder.service'; +import { ReminderController } from './reminder.controller'; +import { SchedulerService } from './services/scheduler.service'; +import { EscalationService } from './services/escalation.service'; +import { ReminderProcessor } from './processors/reminder.processor'; +import { QUEUE_REMINDERS } from '../common/constants/queue.constants'; +import { NotificationModule } from '../notification/notification.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ReminderRule, ReviewTask]), + BullModule.registerQueue({ name: QUEUE_REMINDERS }), + NotificationModule, + ], + providers: [ + ReminderService, + SchedulerService, + EscalationService, + ReminderProcessor, + ], + controllers: [ReminderController], + exports: [ReminderService, SchedulerService, EscalationService], +}) +export class ReminderModule {} diff --git a/backend/src/modules/reminder/reminder.service.ts b/backend/src/modules/reminder/reminder.service.ts new file mode 100644 index 00000000..20cd05e1 --- /dev/null +++ b/backend/src/modules/reminder/reminder.service.ts @@ -0,0 +1,51 @@ +// File: src/modules/reminder/reminder.service.ts +// ReminderService — CRUD สำหรับ ReminderRule entities (T044) +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ReminderRule } from './entities/reminder-rule.entity'; +import { CreateReminderRuleDto } from './dto/create-reminder-rule.dto'; + +export { CreateReminderRuleDto }; + +@Injectable() +export class ReminderService { + private readonly logger = new Logger(ReminderService.name); + + constructor( + @InjectRepository(ReminderRule) + private readonly ruleRepo: Repository, + ) {} + + async findAll(projectId?: number): Promise { + if (projectId !== undefined) { + return this.ruleRepo.find({ + where: [{ projectId }, { projectId: undefined }], + order: { escalationLevel: 'ASC', daysBeforeDue: 'DESC' }, + }); + } + return this.ruleRepo.find({ order: { escalationLevel: 'ASC' } }); + } + + async findOne(publicId: string): Promise { + const rule = await this.ruleRepo.findOne({ where: { publicId } }); + if (!rule) throw new NotFoundException(`ReminderRule not found: ${publicId}`); + return rule; + } + + async create(dto: CreateReminderRuleDto): Promise { + const rule = this.ruleRepo.create(dto as Partial); + return this.ruleRepo.save(rule); + } + + async update(publicId: string, dto: Partial): Promise { + const rule = await this.findOne(publicId); + Object.assign(rule, dto); + return this.ruleRepo.save(rule); + } + + async remove(publicId: string): Promise { + const rule = await this.findOne(publicId); + await this.ruleRepo.remove(rule); + } +} diff --git a/backend/src/modules/reminder/services/escalation.service.ts b/backend/src/modules/reminder/services/escalation.service.ts new file mode 100644 index 00000000..b87c44c2 --- /dev/null +++ b/backend/src/modules/reminder/services/escalation.service.ts @@ -0,0 +1,112 @@ +// File: src/modules/reminder/services/escalation.service.ts +// 2-Level Escalation เมื่อ Review Task เกิน due date (FR-015) +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, LessThan } from 'typeorm'; +import { ReviewTask } from '../../review-team/entities/review-task.entity'; +import { ReviewTaskStatus } from '../../common/enums/review.enums'; +import { NotificationService } from '../../notification/notification.service'; +import { ReminderRule } from '../entities/reminder-rule.entity'; + +@Injectable() +export class EscalationService { + private readonly logger = new Logger(EscalationService.name); + + constructor( + @InjectRepository(ReviewTask) + private readonly reviewTaskRepo: Repository, + @InjectRepository(ReminderRule) + private readonly reminderRuleRepo: Repository, + private readonly notificationService: NotificationService, + ) {} + + /** + * Escalation Level 1 (FR-015): Team Lead ได้รับแจ้งเตือน + * เรียกเมื่อ task เกิน due date 1 วัน + */ + async escalateLevel1(taskPublicId: string): Promise { + const task = await this.reviewTaskRepo.findOne({ + where: { publicId: taskPublicId }, + relations: ['team', 'assignedToUser', 'discipline'], + }); + + if (!task || task.status === ReviewTaskStatus.COMPLETED) return; + + const daysOverdue = task.dueDate + ? Math.floor((Date.now() - task.dueDate.getTime()) / 86_400_000) + : 0; + + if (daysOverdue < 1) return; + + this.logger.log( + `Escalation L1: task ${taskPublicId} is ${daysOverdue} days overdue`, + ); + + // แจ้ง Team Lead + if (task.assignedToUserId) { + await this.notificationService.send({ + userId: task.assignedToUserId, + title: `⚠ Review Task Overdue (${daysOverdue}d)`, + message: `Your review task is overdue by ${daysOverdue} day(s). Please complete it immediately.`, + type: 'SYSTEM', + entityType: 'review_task', + entityId: task.id, + }); + } + } + + /** + * Escalation Level 2 (FR-016): Project Manager ได้รับแจ้งเตือน + * เรียกเมื่อ task เกิน due date 3 วัน + */ + async escalateLevel2(taskPublicId: string): Promise { + const task = await this.reviewTaskRepo.findOne({ + where: { publicId: taskPublicId }, + relations: ['team', 'assignedToUser'], + }); + + if (!task || task.status === ReviewTaskStatus.COMPLETED) return; + + const daysOverdue = task.dueDate + ? Math.floor((Date.now() - task.dueDate.getTime()) / 86_400_000) + : 0; + + if (daysOverdue < 3) return; + + this.logger.warn( + `Escalation L2: task ${taskPublicId} is ${daysOverdue} days overdue — escalating to PM`, + ); + + // TODO: ดึง PM user ID จาก project membership — ใช้ placeholder สำหรับตอนนี้ + this.logger.log(`L2 escalation notification queued for task ${taskPublicId}`); + } + + /** + * สแกน tasks ที่ overdue ทั้งหมด และ escalate ตาม level (cron trigger) + */ + async processOverdueTasks(): Promise { + const now = new Date(); + + const overdueTasks = await this.reviewTaskRepo.find({ + where: { + status: ReviewTaskStatus.IN_PROGRESS, + dueDate: LessThan(now), + }, + select: ['publicId', 'dueDate'], + }); + + this.logger.log(`Processing ${overdueTasks.length} overdue tasks`); + + for (const task of overdueTasks) { + const daysOverdue = task.dueDate + ? Math.floor((Date.now() - task.dueDate.getTime()) / 86_400_000) + : 0; + + if (daysOverdue >= 3) { + await this.escalateLevel2(task.publicId); + } else if (daysOverdue >= 1) { + await this.escalateLevel1(task.publicId); + } + } + } +} diff --git a/backend/src/modules/reminder/services/scheduler.service.ts b/backend/src/modules/reminder/services/scheduler.service.ts new file mode 100644 index 00000000..50688cfb --- /dev/null +++ b/backend/src/modules/reminder/services/scheduler.service.ts @@ -0,0 +1,97 @@ +// File: src/modules/reminder/services/scheduler.service.ts +// Schedule reminders เมื่อ RFA submit (FR-013) — เพิ่ม jobs เข้า BullMQ queue +import { Injectable, Logger } from '@nestjs/common'; +import { InjectQueue } from '@nestjs/bullmq'; +import { Queue } from 'bullmq'; +import { QUEUE_REMINDERS } from '../../common/constants/queue.constants'; +import type { Job } from 'bullmq'; +import { ReminderType } from '../../common/enums/review.enums'; + +export interface ScheduleReminderPayload { + taskPublicId: string; + rfaPublicId: string; + assigneeUserId: number; + dueDate: Date; + reminderType: ReminderType; +} + +@Injectable() +export class SchedulerService { + private readonly logger = new Logger(SchedulerService.name); + + constructor( + @InjectQueue(QUEUE_REMINDERS) + private readonly reminderQueue: Queue, + ) {} + + /** + * Schedule ชุด reminders ให้ Review Task (FR-013) + * เรียกหลังจาก TaskCreationService สร้าง tasks เรียบร้อยแล้ว + */ + async scheduleForTask(payload: ScheduleReminderPayload): Promise { + const { taskPublicId, dueDate } = payload; + const now = Date.now(); + + const remindersToSchedule: Array<{ type: ReminderType; delayMs: number }> = []; + + // 2 วันก่อน due date + const twoDaysBefore = dueDate.getTime() - 2 * 86_400_000; + if (twoDaysBefore > now) { + remindersToSchedule.push({ + type: ReminderType.DUE_SOON, + delayMs: twoDaysBefore - now, + }); + } + + // วัน due date เอง + const onDue = dueDate.getTime(); + if (onDue > now) { + remindersToSchedule.push({ + type: ReminderType.ON_DUE, + delayMs: onDue - now, + }); + } + + // 1 วันหลัง due (Escalation L1) + const oneDayAfter = dueDate.getTime() + 1 * 86_400_000; + remindersToSchedule.push({ + type: ReminderType.ESCALATION_L1, + delayMs: Math.max(oneDayAfter - now, 0), + }); + + // 3 วันหลัง due (Escalation L2) + const threeDaysAfter = dueDate.getTime() + 3 * 86_400_000; + remindersToSchedule.push({ + type: ReminderType.ESCALATION_L2, + delayMs: Math.max(threeDaysAfter - now, 0), + }); + + await Promise.all( + remindersToSchedule.map(({ type, delayMs }) => + this.reminderQueue.add( + 'send-reminder', + { ...payload, reminderType: type }, + { delay: delayMs, removeOnComplete: true, removeOnFail: 100 }, + ), + ), + ); + + this.logger.log( + `Scheduled ${remindersToSchedule.length} reminders for task ${taskPublicId}`, + ); + } + + /** + * ยกเลิก reminders ทั้งหมดของ task (เมื่อ task complete หรือ cancelled) + */ + async cancelForTask(taskPublicId: string): Promise { + const jobs = await this.reminderQueue.getDelayed(); + const taskJobs = jobs.filter((j: Job) => j.data?.taskPublicId === taskPublicId); + + await Promise.all(taskJobs.map((j: Job) => j.remove())); + + this.logger.log( + `Cancelled ${taskJobs.length} reminder jobs for task ${taskPublicId}`, + ); + } +} diff --git a/backend/src/modules/response-code/entities/response-code-rule.entity.ts b/backend/src/modules/response-code/entities/response-code-rule.entity.ts new file mode 100644 index 00000000..9e26fbf6 --- /dev/null +++ b/backend/src/modules/response-code/entities/response-code-rule.entity.ts @@ -0,0 +1,56 @@ +// File: src/modules/response-code/entities/response-code-rule.entity.ts +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Exclude } from 'class-transformer'; +import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity'; +import { ResponseCode } from './response-code.entity'; + +@Entity('response_code_rules') +export class ResponseCodeRule extends UuidBaseEntity { + @PrimaryGeneratedColumn() + @Exclude() + id!: number; + + @Column({ name: 'project_id', nullable: true }) + @Exclude() + projectId?: number; // NULL = global default + + @Column({ name: 'document_type_id' }) + @Exclude() + documentTypeId!: number; + + @Column({ name: 'response_code_id' }) + @Exclude() + responseCodeId!: number; + + @Column({ name: 'is_enabled', type: 'tinyint', default: 1 }) + isEnabled!: boolean; + + @Column({ name: 'requires_comments', type: 'tinyint', default: 0 }) + requiresComments!: boolean; + + @Column({ name: 'triggers_notification', type: 'tinyint', default: 0 }) + triggersNotification!: boolean; + + @Column({ name: 'parent_rule_id', nullable: true }) + @Exclude() + parentRuleId?: number; // Inheritance tracking + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt!: Date; + + // Relations + @ManyToOne(() => ResponseCode, (code: ResponseCode) => code.rules) + @JoinColumn({ name: 'response_code_id' }) + responseCode!: ResponseCode; +} diff --git a/backend/src/modules/response-code/entities/response-code.entity.ts b/backend/src/modules/response-code/entities/response-code.entity.ts new file mode 100644 index 00000000..4361144d --- /dev/null +++ b/backend/src/modules/response-code/entities/response-code.entity.ts @@ -0,0 +1,61 @@ +// File: src/modules/response-code/entities/response-code.entity.ts +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + OneToMany, +} from 'typeorm'; +import { Exclude } from 'class-transformer'; +import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity'; +import { ResponseCodeCategory } from '../../common/enums/review.enums'; +import { ResponseCodeRule } from './response-code-rule.entity'; + +@Entity('response_codes') +export class ResponseCode extends UuidBaseEntity { + @PrimaryGeneratedColumn() + @Exclude() + id!: number; + + @Column({ length: 10 }) + code!: string; // '1A', '1B', '1C', ..., '2', '3', '4' + + @Column({ name: 'sub_status', length: 10, nullable: true }) + subStatus?: string; + + @Column({ + type: 'enum', + enum: ResponseCodeCategory, + }) + category!: ResponseCodeCategory; + + @Column({ name: 'description_th', type: 'text' }) + descriptionTh!: string; + + @Column({ name: 'description_en', type: 'text' }) + descriptionEn!: string; + + @Column({ type: 'json', nullable: true }) + implications?: { + affectsSchedule?: boolean; + affectsCost?: boolean; + requiresContractReview?: boolean; + requiresEiaAmendment?: boolean; + }; + + @Column({ name: 'notify_roles', type: 'simple-array', nullable: true }) + notifyRoles?: string[]; // ['CONTRACT_MANAGER', 'QS_MANAGER'] + + @Column({ name: 'is_active', type: 'tinyint', default: 1 }) + isActive!: boolean; + + @Column({ name: 'is_system', type: 'tinyint', default: 0 }) + isSystem!: boolean; // System default — ลบไม่ได้ + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; + + // Relations + @OneToMany(() => ResponseCodeRule, (rule: ResponseCodeRule) => rule.responseCode) + rules?: ResponseCodeRule[]; +} diff --git a/backend/src/modules/response-code/response-code.controller.ts b/backend/src/modules/response-code/response-code.controller.ts new file mode 100644 index 00000000..7c9fba4f --- /dev/null +++ b/backend/src/modules/response-code/response-code.controller.ts @@ -0,0 +1,53 @@ +// File: src/modules/response-code/response-code.controller.ts +import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { ResponseCodeService } from './response-code.service'; +import { ResponseCodeCategory } from '../common/enums/review.enums'; + +@Controller('response-codes') +@UseGuards(JwtAuthGuard) +export class ResponseCodeController { + constructor(private readonly responseCodeService: ResponseCodeService) {} + + /** + * GET /response-codes + * ดึง Response Codes ทั้งหมด + */ + @Get() + findAll() { + return this.responseCodeService.findAll(); + } + + /** + * GET /response-codes/category/:category + * ดึง Response Codes ตาม Category (FR-006) + */ + @Get('category/:category') + findByCategory(@Param('category') category: ResponseCodeCategory) { + return this.responseCodeService.findByCategory(category); + } + + /** + * GET /response-codes/document-type/:id + * ดึง Response Codes ที่ใช้ได้กับ document type + project + */ + @Get('document-type/:documentTypeId') + findByDocumentType( + @Param('documentTypeId') documentTypeId: string, + @Query('projectId') projectId?: string, + ) { + return this.responseCodeService.findByDocumentType( + Number(documentTypeId), + projectId ? Number(projectId) : undefined, + ); + } + + /** + * GET /response-codes/:publicId + * ดึง Response Code ตาม publicId (ADR-019) + */ + @Get(':publicId') + findOne(@Param('publicId') publicId: string) { + return this.responseCodeService.findByPublicId(publicId); + } +} diff --git a/backend/src/modules/response-code/response-code.module.ts b/backend/src/modules/response-code/response-code.module.ts new file mode 100644 index 00000000..b905d64b --- /dev/null +++ b/backend/src/modules/response-code/response-code.module.ts @@ -0,0 +1,36 @@ +// File: src/modules/response-code/response-code.module.ts +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ResponseCode } from './entities/response-code.entity'; +import { ResponseCodeRule } from './entities/response-code-rule.entity'; +import { ResponseCodeService } from './response-code.service'; +import { ResponseCodeController } from './response-code.controller'; +import { ImplicationsService } from './services/implications.service'; +import { NotificationTriggerService } from './services/notification-trigger.service'; +import { MatrixManagementService } from './services/matrix-management.service'; +import { InheritanceService } from './services/inheritance.service'; +import { User } from '../user/entities/user.entity'; +import { NotificationModule } from '../notification/notification.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ResponseCode, ResponseCodeRule, User]), + NotificationModule, + ], + providers: [ + ResponseCodeService, + ImplicationsService, + NotificationTriggerService, + MatrixManagementService, + InheritanceService, + ], + controllers: [ResponseCodeController], + exports: [ + ResponseCodeService, + ImplicationsService, + NotificationTriggerService, + MatrixManagementService, + InheritanceService, + ], +}) +export class ResponseCodeModule {} diff --git a/backend/src/modules/response-code/response-code.service.ts b/backend/src/modules/response-code/response-code.service.ts new file mode 100644 index 00000000..1e8387b2 --- /dev/null +++ b/backend/src/modules/response-code/response-code.service.ts @@ -0,0 +1,94 @@ +// File: src/modules/response-code/response-code.service.ts +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, IsNull } from 'typeorm'; +import { ResponseCode } from './entities/response-code.entity'; +import { ResponseCodeRule } from './entities/response-code-rule.entity'; +import { ResponseCodeCategory } from '../common/enums/review.enums'; + +@Injectable() +export class ResponseCodeService { + private readonly logger = new Logger(ResponseCodeService.name); + + constructor( + @InjectRepository(ResponseCode) + private readonly responseCodeRepo: Repository, + @InjectRepository(ResponseCodeRule) + private readonly responseCodeRuleRepo: Repository, + ) {} + + /** + * ดึง Response Codes ทั้งหมดที่ active + */ + async findAll(): Promise { + return this.responseCodeRepo.find({ + where: { isActive: true }, + order: { category: 'ASC', code: 'ASC' }, + }); + } + + /** + * ดึง Response Codes ตาม Category (FR-006) + * ใช้สำหรับแสดงผลใน Review page ตามประเภทเอกสาร + */ + async findByCategory(category: ResponseCodeCategory): Promise { + return this.responseCodeRepo.find({ + where: { category, isActive: true }, + order: { code: 'ASC' }, + }); + } + + /** + * ดึง Response Codes ที่ใช้ได้กับ document type + project + * รองรับ Global default + Project override (ADR-019 Q1 clarification) + */ + async findByDocumentType( + documentTypeId: number, + projectId?: number, + ): Promise { + // ดึง Rules ระดับ Project (ถ้ามี) หรือ Global default + const rules = await this.responseCodeRuleRepo.find({ + where: [ + { documentTypeId, projectId: projectId ?? IsNull(), isEnabled: true }, + { documentTypeId, projectId: IsNull(), isEnabled: true }, + ], + relations: ['responseCode'], + }); + + // Project rules override global rules + const codeMap = new Map(); + for (const rule of rules) { + if (rule.responseCode?.isActive) { + codeMap.set(rule.responseCodeId, rule.responseCode); + } + } + + return Array.from(codeMap.values()).sort((a, b) => + a.code.localeCompare(b.code), + ); + } + + /** + * ดึง ResponseCode โดย publicId (ADR-019) + */ + async findByPublicId(publicId: string): Promise { + const code = await this.responseCodeRepo.findOne({ + where: { publicId }, + }); + + if (!code) { + throw new NotFoundException(`Response Code not found: ${publicId}`); + } + + return code; + } + + /** + * ตรวจสอบว่า Response Code triggers notification หรือไม่ (FR-007) + * Code 1C, 1D, 3 → trigger notification + */ + async getNotifyRoles(responseCodePublicId: string): Promise { + const code = await this.findByPublicId(responseCodePublicId); + return code.notifyRoles ?? []; + } +} diff --git a/backend/src/modules/response-code/seeders/response-code.seed.ts b/backend/src/modules/response-code/seeders/response-code.seed.ts new file mode 100644 index 00000000..64ca5eec --- /dev/null +++ b/backend/src/modules/response-code/seeders/response-code.seed.ts @@ -0,0 +1,281 @@ +// File: src/modules/response-code/seeders/response-code.seed.ts +// Seed data สำหรับ Master Approval Matrix — Response Codes มาตรฐาน +// อ้างอิง: specs/1-rfa-approval-refactor/spec.md — Comprehensive Master Approval Matrix + +import { DataSource } from 'typeorm'; +import { ResponseCode } from '../entities/response-code.entity'; +import { ResponseCodeCategory } from '../../common/enums/review.enums'; + +export const responseCodeSeedData = [ + // ─── ENGINEERING Category (Shop Drawing, Method Statement, As-Built) ─────── + { + code: '1A', + category: ResponseCodeCategory.ENGINEERING, + descriptionTh: 'อนุมัติเพื่อก่อสร้าง', + descriptionEn: 'Approved for Construction', + implications: { affectsSchedule: false, affectsCost: false, requiresContractReview: false }, + notifyRoles: [], + isSystem: true, + }, + { + code: '1B', + category: ResponseCodeCategory.ENGINEERING, + descriptionTh: 'อนุมัติเพื่อก่อสร้าง พร้อมความเห็น (แก้ไขไม่ต้องส่งกลับ)', + descriptionEn: 'Approved for Construction with Comments (No Resubmission Required)', + implications: { affectsSchedule: false, affectsCost: false, requiresContractReview: false }, + notifyRoles: [], + isSystem: true, + }, + { + code: '1C', + category: ResponseCodeCategory.ENGINEERING, + descriptionTh: 'อนุมัติ — มีผลต่อสัญญา/Change Order', + descriptionEn: 'Approved — Contract Implications / Change Order Required', + implications: { affectsSchedule: true, affectsCost: true, requiresContractReview: true }, + notifyRoles: ['CONTRACT_MANAGER', 'QS_MANAGER'], + isSystem: true, + }, + { + code: '1D', + category: ResponseCodeCategory.ENGINEERING, + descriptionTh: 'อนุมัติทางเลือก — แตกต่างจากแบบสัญญา', + descriptionEn: 'Approved Alternative — Differs from Contract Drawing', + implications: { affectsSchedule: false, affectsCost: true, requiresContractReview: true }, + notifyRoles: ['CONTRACT_MANAGER', 'DESIGN_MANAGER'], + isSystem: true, + }, + { + code: '1E', + category: ResponseCodeCategory.ENGINEERING, + descriptionTh: 'อนุมัติเพื่อวัตถุประสงค์การออกแบบเท่านั้น', + descriptionEn: 'Approved for Design Purpose Only', + implications: { affectsSchedule: false, affectsCost: false, requiresContractReview: false }, + notifyRoles: [], + isSystem: true, + }, + { + code: '1F', + category: ResponseCodeCategory.ENGINEERING, + descriptionTh: 'อนุมัติเพื่ออ้างอิงเท่านั้น', + descriptionEn: 'Approved for Reference Only', + implications: { affectsSchedule: false, affectsCost: false, requiresContractReview: false }, + notifyRoles: [], + isSystem: true, + }, + { + code: '1G', + category: ResponseCodeCategory.ENGINEERING, + descriptionTh: 'อนุมัติพร้อมเงื่อนไข ESG', + descriptionEn: 'Approved with ESG Conditions', + implications: { affectsSchedule: true, affectsCost: false, requiresContractReview: false, requiresEiaAmendment: true }, + notifyRoles: ['EIA_OFFICER', 'HSE_MANAGER'], + isSystem: true, + }, + { + code: '2', + category: ResponseCodeCategory.ENGINEERING, + descriptionTh: 'อนุมัติตามหมายเหตุ — ต้องแก้ไขและส่งกลับเพื่อตรวจสอบ', + descriptionEn: 'Approved as Noted — Revise and Resubmit for Review', + implications: { affectsSchedule: true, affectsCost: false, requiresContractReview: false }, + notifyRoles: [], + isSystem: true, + }, + { + code: '3', + category: ResponseCodeCategory.ENGINEERING, + descriptionTh: 'ปฏิเสธ — ต้องแก้ไขและส่งใหม่', + descriptionEn: 'Rejected — Revise and Resubmit', + implications: { affectsSchedule: true, affectsCost: false, requiresContractReview: false }, + notifyRoles: ['PROJECT_MANAGER', 'DESIGN_MANAGER'], + isSystem: true, + }, + { + code: '4', + category: ResponseCodeCategory.ENGINEERING, + descriptionTh: 'ไม่เกี่ยวข้อง / ถอนคืน', + descriptionEn: 'Not Applicable / Withdrawn', + implications: { affectsSchedule: false, affectsCost: false, requiresContractReview: false }, + notifyRoles: [], + isSystem: true, + }, + + // ─── MATERIAL Category ──────────────────────────────────────────────────── + { + code: '1A', + category: ResponseCodeCategory.MATERIAL, + descriptionTh: 'อนุมัติวัสดุ/อุปกรณ์เพื่อจัดซื้อ', + descriptionEn: 'Approved for Procurement', + implications: { affectsSchedule: false, affectsCost: false, requiresContractReview: false }, + notifyRoles: [], + isSystem: true, + }, + { + code: '1B', + category: ResponseCodeCategory.MATERIAL, + descriptionTh: 'อนุมัติวัสดุ/อุปกรณ์ พร้อมความเห็น', + descriptionEn: 'Approved for Procurement with Comments', + implications: { affectsSchedule: false, affectsCost: false, requiresContractReview: false }, + notifyRoles: [], + isSystem: true, + }, + { + code: '1C', + category: ResponseCodeCategory.MATERIAL, + descriptionTh: 'อนุมัติ — มีผลต่อค่าใช้จ่าย', + descriptionEn: 'Approved — Cost Implications', + implications: { affectsSchedule: false, affectsCost: true, requiresContractReview: true }, + notifyRoles: ['QS_MANAGER', 'PROCUREMENT_MANAGER'], + isSystem: true, + }, + { + code: '2', + category: ResponseCodeCategory.MATERIAL, + descriptionTh: 'ส่งข้อมูลเพิ่มเติม', + descriptionEn: 'Provide Additional Information', + implications: { affectsSchedule: true, affectsCost: false, requiresContractReview: false }, + notifyRoles: [], + isSystem: true, + }, + { + code: '3', + category: ResponseCodeCategory.MATERIAL, + descriptionTh: 'ปฏิเสธ — ไม่เป็นไปตามสัญญา', + descriptionEn: 'Rejected — Non-Compliant with Contract', + implications: { affectsSchedule: true, affectsCost: false, requiresContractReview: false }, + notifyRoles: ['PROJECT_MANAGER', 'PROCUREMENT_MANAGER'], + isSystem: true, + }, + { + code: '4', + category: ResponseCodeCategory.MATERIAL, + descriptionTh: 'ไม่เกี่ยวข้อง / ถอนคืน', + descriptionEn: 'Not Applicable / Withdrawn', + implications: { affectsSchedule: false, affectsCost: false, requiresContractReview: false }, + notifyRoles: [], + isSystem: true, + }, + + // ─── CONTRACT Category ──────────────────────────────────────────────────── + { + code: '1A', + category: ResponseCodeCategory.CONTRACT, + descriptionTh: 'อนุมัติ — ไม่มีผลต่อสัญญา', + descriptionEn: 'Approved — No Contract Implication', + implications: { affectsSchedule: false, affectsCost: false, requiresContractReview: false }, + notifyRoles: [], + isSystem: true, + }, + { + code: '1C', + category: ResponseCodeCategory.CONTRACT, + descriptionTh: 'อนุมัติ — ต้องออก Change Order', + descriptionEn: 'Approved — Change Order Required', + implications: { affectsSchedule: true, affectsCost: true, requiresContractReview: true }, + notifyRoles: ['CONTRACT_MANAGER', 'QS_MANAGER', 'PROJECT_MANAGER'], + isSystem: true, + }, + { + code: '2', + category: ResponseCodeCategory.CONTRACT, + descriptionTh: 'อยู่ระหว่างการพิจารณา — ต้องการข้อมูลเพิ่มเติม', + descriptionEn: 'Under Review — Additional Information Required', + implications: { affectsSchedule: true, affectsCost: false, requiresContractReview: false }, + notifyRoles: [], + isSystem: true, + }, + { + code: '3', + category: ResponseCodeCategory.CONTRACT, + descriptionTh: 'ปฏิเสธ — ขัดต่อเงื่อนไขสัญญา', + descriptionEn: 'Rejected — Contradicts Contract Terms', + implications: { affectsSchedule: true, affectsCost: true, requiresContractReview: true }, + notifyRoles: ['CONTRACT_MANAGER', 'LEGAL_TEAM', 'PROJECT_MANAGER'], + isSystem: true, + }, + + // ─── TESTING Category ───────────────────────────────────────────────────── + { + code: '1A', + category: ResponseCodeCategory.TESTING, + descriptionTh: 'อนุมัติผลการทดสอบ / ส่งมอบ', + descriptionEn: 'Approved — Test Results / Handover Accepted', + implications: { affectsSchedule: false, affectsCost: false, requiresContractReview: false }, + notifyRoles: [], + isSystem: true, + }, + { + code: '2', + category: ResponseCodeCategory.TESTING, + descriptionTh: 'ผ่านพร้อมข้อบกพร่องเล็กน้อย — ต้องแก้ไขและรายงาน', + descriptionEn: 'Passed with Minor Defects — Rectify and Report', + implications: { affectsSchedule: true, affectsCost: false, requiresContractReview: false }, + notifyRoles: ['QA_MANAGER'], + isSystem: true, + }, + { + code: '3', + category: ResponseCodeCategory.TESTING, + descriptionTh: 'ไม่ผ่าน — ต้องทดสอบซ้ำ', + descriptionEn: 'Failed — Retest Required', + implications: { affectsSchedule: true, affectsCost: true, requiresContractReview: false }, + notifyRoles: ['PROJECT_MANAGER', 'QA_MANAGER'], + isSystem: true, + }, + + // ─── ESG Category ────────────────────────────────────────────────────────── + { + code: '1A', + category: ResponseCodeCategory.ESG, + descriptionTh: 'อนุมัติ — เป็นไปตามมาตรฐาน ESG', + descriptionEn: 'Approved — ESG Compliant', + implications: { affectsSchedule: false, affectsCost: false, requiresContractReview: false }, + notifyRoles: [], + isSystem: true, + }, + { + code: '1G', + category: ResponseCodeCategory.ESG, + descriptionTh: 'อนุมัติพร้อมเงื่อนไขด้านสิ่งแวดล้อม', + descriptionEn: 'Approved with Environmental Conditions', + implications: { affectsSchedule: true, affectsCost: false, requiresContractReview: false, requiresEiaAmendment: true }, + notifyRoles: ['EIA_OFFICER', 'HSE_MANAGER'], + isSystem: true, + }, + { + code: '3', + category: ResponseCodeCategory.ESG, + descriptionTh: 'ปฏิเสธ — ไม่เป็นไปตามข้อกำหนด EIA/ESG', + descriptionEn: 'Rejected — Non-Compliant with EIA/ESG Requirements', + implications: { affectsSchedule: true, affectsCost: true, requiresContractReview: false, requiresEiaAmendment: true }, + notifyRoles: ['EIA_OFFICER', 'HSE_MANAGER', 'PROJECT_MANAGER'], + isSystem: true, + }, +]; + +/** + * Seed Response Codes ลงฐานข้อมูล + * ใช้สำหรับ initial setup และ test environments + */ +export async function seedResponseCodes(dataSource: DataSource): Promise { + const repo = dataSource.getRepository(ResponseCode); + + for (const data of responseCodeSeedData) { + const exists = await repo.findOne({ + where: { code: data.code, category: data.category as ResponseCodeCategory }, + }); + + if (!exists) { + const entity = repo.create({ + code: data.code, + category: data.category as ResponseCodeCategory, + descriptionTh: data.descriptionTh, + descriptionEn: data.descriptionEn, + implications: data.implications, + notifyRoles: data.notifyRoles, + isSystem: data.isSystem, + isActive: true, + }); + await repo.save(entity); + } + } +} diff --git a/backend/src/modules/response-code/services/implications.service.ts b/backend/src/modules/response-code/services/implications.service.ts new file mode 100644 index 00000000..2c7754b9 --- /dev/null +++ b/backend/src/modules/response-code/services/implications.service.ts @@ -0,0 +1,110 @@ +// File: src/modules/response-code/services/implications.service.ts +// ประเมินผลกระทบของ Response Code ที่เลือก (FR-007) +import { Injectable, Logger } from '@nestjs/common'; +import { ResponseCode } from '../entities/response-code.entity'; + +export interface CodeImplicationResult { + affectsSchedule: boolean; + affectsCost: boolean; + requiresContractReview: boolean; + requiresEiaAmendment: boolean; + notifyRoles: string[]; + severity: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL'; + actionRequired: string[]; +} + +@Injectable() +export class ImplicationsService { + private readonly logger = new Logger(ImplicationsService.name); + + /** + * ประเมินผลกระทบของ Response Code (FR-007) + * Code 1C, 1D, 3 → Critical → trigger notifications + */ + evaluate(responseCode: ResponseCode): CodeImplicationResult { + const implications = responseCode.implications ?? {}; + const notifyRoles = responseCode.notifyRoles ?? []; + + const affectsSchedule = implications.affectsSchedule ?? false; + const affectsCost = implications.affectsCost ?? false; + const requiresContractReview = implications.requiresContractReview ?? false; + const requiresEiaAmendment = implications.requiresEiaAmendment ?? false; + + // กำหนด severity ตามน้ำหนักผลกระทบ + const severity = this.calculateSeverity( + responseCode.code, + affectsSchedule, + affectsCost, + requiresContractReview, + ); + + const actionRequired = this.buildActionList( + responseCode.code, + requiresContractReview, + requiresEiaAmendment, + affectsCost, + ); + + return { + affectsSchedule, + affectsCost, + requiresContractReview, + requiresEiaAmendment, + notifyRoles, + severity, + actionRequired, + }; + } + + private calculateSeverity( + code: string, + affectsSchedule: boolean, + affectsCost: boolean, + requiresContractReview: boolean, + ): CodeImplicationResult['severity'] { + // Code 3 (Rejected) = CRITICAL เสมอ + if (code === '3') return 'CRITICAL'; + + // Code 1C (Contract Implications) หรือ 1D (Alternative) = HIGH + if (code === '1C' || code === '1D') return 'HIGH'; + + // มีผลต่อทั้ง schedule และ cost + if (affectsSchedule && affectsCost) return 'HIGH'; + + // มีผลต่ออย่างใดอย่างหนึ่ง + if (requiresContractReview || affectsSchedule || affectsCost) return 'MEDIUM'; + + return 'LOW'; + } + + private buildActionList( + code: string, + requiresContractReview: boolean, + requiresEiaAmendment: boolean, + affectsCost: boolean, + ): string[] { + const actions: string[] = []; + + if (code === '3') { + actions.push('Document rejected — originator must revise and resubmit'); + } + + if (requiresContractReview) { + actions.push('Contract review required — notify Contract Manager'); + } + + if (affectsCost) { + actions.push('Cost impact assessment required — notify QS Manager'); + } + + if (requiresEiaAmendment) { + actions.push('EIA amendment may be required — notify EIA Officer'); + } + + if (code === '2') { + actions.push('Minor comments — originator to revise and resubmit'); + } + + return actions; + } +} diff --git a/backend/src/modules/response-code/services/inheritance.service.ts b/backend/src/modules/response-code/services/inheritance.service.ts new file mode 100644 index 00000000..06374f6d --- /dev/null +++ b/backend/src/modules/response-code/services/inheritance.service.ts @@ -0,0 +1,121 @@ +// File: src/modules/response-code/services/inheritance.service.ts +// Resolves project-level overrides inheriting from global defaults (T062, FR-021) +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ResponseCodeRule } from '../entities/response-code-rule.entity'; + +export interface ResolvedMatrix { + responseCodeId: number; + responseCodePublicId: string; + documentTypeId: number; + isEnabled: boolean; + requiresComments: boolean; + triggersNotification: boolean; + isOverridden: boolean; // true = project-specific rule overrides global + parentRuleId?: number; +} + +@Injectable() +export class InheritanceService { + private readonly logger = new Logger(InheritanceService.name); + + constructor( + @InjectRepository(ResponseCodeRule) + private readonly ruleRepo: Repository, + ) {} + + /** + * ดึง rules สำหรับ document type โดย merge global + project overrides (FR-021) + * Project rule ชนะ global rule ของ responseCode เดียวกัน + * + * @param documentTypeId - document type ที่ต้องการ + * @param projectId - project ID (NULL = global only) + */ + async resolveMatrix( + documentTypeId: number, + projectId?: number, + ): Promise { + // ดึง global rules (projectId IS NULL) + const globalRules = await this.ruleRepo.find({ + where: { documentTypeId, projectId: undefined }, + relations: ['responseCode'], + }); + + if (!projectId) { + return globalRules.map((r) => ({ + responseCodeId: r.responseCodeId, + responseCodePublicId: r.responseCode.publicId, + documentTypeId: r.documentTypeId, + isEnabled: r.isEnabled, + requiresComments: r.requiresComments, + triggersNotification: r.triggersNotification, + isOverridden: false, + parentRuleId: undefined, + })); + } + + // ดึง project-specific overrides + const projectRules = await this.ruleRepo.find({ + where: { documentTypeId, projectId }, + relations: ['responseCode'], + }); + + // Build map: responseCodeId → project rule + const projectRuleMap = new Map( + projectRules.map((r) => [r.responseCodeId, r]), + ); + + // Merge: project overrides global + const merged: ResolvedMatrix[] = globalRules.map((global) => { + const override = projectRuleMap.get(global.responseCodeId); + if (override) { + return { + responseCodeId: override.responseCodeId, + responseCodePublicId: override.responseCode.publicId, + documentTypeId: override.documentTypeId, + isEnabled: override.isEnabled, + requiresComments: override.requiresComments, + triggersNotification: override.triggersNotification, + isOverridden: true, + parentRuleId: global.id, + }; + } + return { + responseCodeId: global.responseCodeId, + responseCodePublicId: global.responseCode.publicId, + documentTypeId: global.documentTypeId, + isEnabled: global.isEnabled, + requiresComments: global.requiresComments, + triggersNotification: global.triggersNotification, + isOverridden: false, + parentRuleId: undefined, + }; + }); + + // เพิ่ม project-only rules (ไม่มี global parent) + for (const projectRule of projectRules) { + const alreadyMerged = globalRules.some( + (g) => g.responseCodeId === projectRule.responseCodeId, + ); + if (!alreadyMerged) { + merged.push({ + responseCodeId: projectRule.responseCodeId, + responseCodePublicId: projectRule.responseCode.publicId, + documentTypeId: projectRule.documentTypeId, + isEnabled: projectRule.isEnabled, + requiresComments: projectRule.requiresComments, + triggersNotification: projectRule.triggersNotification, + isOverridden: true, + parentRuleId: undefined, + }); + } + } + + this.logger.debug( + `Resolved ${merged.length} rules for docType=${documentTypeId}, project=${projectId}`, + ); + + return merged; + } +} diff --git a/backend/src/modules/response-code/services/matrix-management.service.ts b/backend/src/modules/response-code/services/matrix-management.service.ts new file mode 100644 index 00000000..598b62ba --- /dev/null +++ b/backend/src/modules/response-code/services/matrix-management.service.ts @@ -0,0 +1,103 @@ +// File: src/modules/response-code/services/matrix-management.service.ts +// CRUD สำหรับ ResponseCodeRule (global + project overrides) (T061, FR-022) +import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ResponseCodeRule } from '../entities/response-code-rule.entity'; +import { ResponseCode } from '../entities/response-code.entity'; + +export interface UpsertRuleDto { + documentTypeId: number; + responseCodePublicId: string; + projectId?: number; + isEnabled: boolean; + requiresComments?: boolean; + triggersNotification?: boolean; +} + +@Injectable() +export class MatrixManagementService { + private readonly logger = new Logger(MatrixManagementService.name); + + constructor( + @InjectRepository(ResponseCodeRule) + private readonly ruleRepo: Repository, + @InjectRepository(ResponseCode) + private readonly codeRepo: Repository, + ) {} + + /** + * Upsert a rule — สร้างใหม่หรือแก้ไข existing rule (FR-022) + */ + async upsertRule(dto: UpsertRuleDto): Promise { + const code = await this.codeRepo.findOne({ + where: { publicId: dto.responseCodePublicId }, + }); + + if (!code) { + throw new NotFoundException(`ResponseCode not found: ${dto.responseCodePublicId}`); + } + + if (code.isSystem && !dto.isEnabled) { + throw new BadRequestException('Cannot disable a system response code'); + } + + const existing = await this.ruleRepo.findOne({ + where: { + documentTypeId: dto.documentTypeId, + responseCodeId: code.id, + projectId: dto.projectId ?? undefined, + }, + }); + + if (existing) { + existing.isEnabled = dto.isEnabled; + existing.requiresComments = dto.requiresComments ?? existing.requiresComments; + existing.triggersNotification = dto.triggersNotification ?? existing.triggersNotification; + return this.ruleRepo.save(existing); + } + + const rule = this.ruleRepo.create({ + documentTypeId: dto.documentTypeId, + responseCodeId: code.id, + projectId: dto.projectId, + isEnabled: dto.isEnabled, + requiresComments: dto.requiresComments ?? false, + triggersNotification: dto.triggersNotification ?? false, + } as Partial); + + return this.ruleRepo.save(rule); + } + + /** + * ดึง rules ทั้งหมดของ document type (global + project) + */ + async getRulesByDocType( + documentTypeId: number, + projectId?: number, + ): Promise { + const where: Record = { documentTypeId }; + if (projectId !== undefined) { + where['projectId'] = projectId; + } else { + where['projectId'] = undefined; // global only + } + + return this.ruleRepo.find({ + where, + relations: ['responseCode'], + }); + } + + /** + * ลบ project override (หวนกลับใช้ global default) + */ + async deleteProjectOverride(rulePublicId: string): Promise { + const rule = await this.ruleRepo.findOne({ where: { publicId: rulePublicId } }); + if (!rule) throw new NotFoundException(rulePublicId); + if (!rule.projectId) { + throw new BadRequestException('Cannot delete a global rule — disable it instead'); + } + await this.ruleRepo.remove(rule); + } +} diff --git a/backend/src/modules/response-code/services/notification-trigger.service.ts b/backend/src/modules/response-code/services/notification-trigger.service.ts new file mode 100644 index 00000000..6d9db5f2 --- /dev/null +++ b/backend/src/modules/response-code/services/notification-trigger.service.ts @@ -0,0 +1,86 @@ +// File: src/modules/response-code/services/notification-trigger.service.ts +// ส่งการแจ้งเตือนอัตโนมัติเมื่อ Response Code มีผลกระทบสำคัญ (FR-007) +// Code 1C (Change Order), 1D (Alternative), 3 (Rejected) → notify ฝ่ายที่เกี่ยวข้อง +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ResponseCode } from '../entities/response-code.entity'; +import { NotificationService } from '../../notification/notification.service'; +import { User } from '../../user/entities/user.entity'; +import { ImplicationsService } from './implications.service'; + +@Injectable() +export class NotificationTriggerService { + private readonly logger = new Logger(NotificationTriggerService.name); + + constructor( + @InjectRepository(ResponseCode) + private readonly responseCodeRepo: Repository, + @InjectRepository(User) + private readonly userRepo: Repository, + private readonly notificationService: NotificationService, + private readonly implicationsService: ImplicationsService, + ) {} + + /** + * Trigger notifications เมื่อ Review Task เสร็จสิ้นด้วย Code ที่มีผลกระทบ (FR-007) + * เรียกจาก ReviewTaskService หลังจาก completeReview + */ + async triggerIfRequired( + responseCodePublicId: string, + rfaPublicId: string, + documentNumber: string, + reviewerUserId: number, + ): Promise { + const responseCode = await this.responseCodeRepo.findOne({ + where: { publicId: responseCodePublicId }, + }); + + if (!responseCode) { + this.logger.warn(`Response code not found for notification trigger: ${responseCodePublicId}`); + return; + } + + const evaluation = this.implicationsService.evaluate(responseCode); + + // ถ้า severity ต่ำ ไม่ต้อง notify เพิ่ม + if (evaluation.severity === 'LOW') return; + + const notifyRoles = evaluation.notifyRoles; + + if (notifyRoles.length === 0) return; + + // หา Users ที่มี role ที่ต้องการแจ้งเตือน + const targetUsers = await this.userRepo + .createQueryBuilder('user') + .where('user.role IN (:...roles)', { roles: notifyRoles }) + .andWhere('user.is_active = 1') + .getMany(); + + const codeLabel = responseCode.code; + const title = `Response Code ${codeLabel} — Action Required`; + const message = [ + `Document: ${documentNumber}`, + `Response Code: ${codeLabel} — ${responseCode.descriptionEn}`, + ...evaluation.actionRequired, + ].join('\n'); + + // ส่งแจ้งเตือนแบบ parallel (ADR-008: ผ่าน BullMQ) + await Promise.all( + targetUsers.map((user: User) => + this.notificationService.send({ + userId: user.user_id, + title, + message, + type: 'SYSTEM', + entityType: 'rfa', + entityId: rfaPublicId as unknown as number, + }), + ), + ); + + this.logger.log( + `Triggered ${notifyRoles.length} role notifications for code ${codeLabel} on document ${documentNumber}`, + ); + } +} diff --git a/backend/src/modules/review-team/dto/shared/review-team.dto.ts b/backend/src/modules/review-team/dto/shared/review-team.dto.ts new file mode 100644 index 00000000..a745eb08 --- /dev/null +++ b/backend/src/modules/review-team/dto/shared/review-team.dto.ts @@ -0,0 +1,179 @@ +// File: src/modules/review-team/dto/shared/review-team.dto.ts +// Shared DTOs สำหรับ Review Team และ Review Task APIs + +import { + IsString, + IsOptional, + IsBoolean, + IsEnum, + IsArray, + IsUUID, + IsDateString, + IsInt, + IsPositive, + MinLength, + MaxLength, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { + ReviewTaskStatus, + ReviewTeamMemberRole, + DelegationScope, +} from '../../../common/enums/review.enums'; + +// ─── Review Team DTOs ────────────────────────────────────────────────────── + +export class CreateReviewTeamDto { + @IsString() + @MinLength(1) + @MaxLength(100) + name!: string; + + @IsOptional() + @IsString() + @MaxLength(255) + description?: string; + + @IsUUID() + projectPublicId!: string; // ADR-019: รับ publicId เสมอ + + @IsOptional() + @IsArray() + @IsString({ each: true }) + defaultForRfaTypes?: string[]; // ['SDW', 'DDW', 'ADW'] +} + +export class UpdateReviewTeamDto { + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(100) + name?: string; + + @IsOptional() + @IsString() + @MaxLength(255) + description?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + defaultForRfaTypes?: string[]; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +export class AddTeamMemberDto { + @IsUUID() + userPublicId!: string; // ADR-019 + + @IsUUID() + disciplinePublicId!: string; // ADR-019 + + @IsEnum(ReviewTeamMemberRole) + role!: ReviewTeamMemberRole; + + @IsOptional() + @IsInt() + @IsPositive() + priorityOrder?: number; +} + +export class SearchReviewTeamDto { + @IsOptional() + @IsUUID() + projectPublicId?: string; + + @IsOptional() + @IsBoolean() + @Type(() => Boolean) + isActive?: boolean; + + @IsOptional() + @IsString() + search?: string; +} + +// ─── Review Task DTOs ────────────────────────────────────────────────────── + +export class CompleteReviewTaskDto { + @IsUUID() + responseCodePublicId!: string; // ADR-019: รับ publicId + + @IsOptional() + @IsString() + comments?: string; + + @IsOptional() + @IsArray() + @IsUUID('all', { each: true }) + attachmentPublicIds?: string[]; +} + +export class UpdateReviewTaskStatusDto { + @IsEnum(ReviewTaskStatus) + status!: ReviewTaskStatus; + + @IsOptional() + @IsString() + reason?: string; +} + +export class SearchReviewTaskDto { + @IsOptional() + @IsUUID() + rfaRevisionPublicId?: string; + + @IsOptional() + @IsEnum(ReviewTaskStatus) + status?: ReviewTaskStatus; + + @IsOptional() + @IsUUID() + assignedToUserPublicId?: string; + + @IsOptional() + @IsDateString() + dueDateFrom?: string; + + @IsOptional() + @IsDateString() + dueDateTo?: string; +} + +// ─── Delegation DTOs ─────────────────────────────────────────────────────── + +export class CreateDelegationDto { + @IsUUID() + delegateePublicId!: string; // ADR-019 + + @IsDateString() + startDate!: string; + + @IsOptional() + @IsDateString() + endDate?: string; + + @IsEnum(DelegationScope) + scope!: DelegationScope; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + documentTypes?: string[]; + + @IsOptional() + @IsString() + reason?: string; +} + +// ─── Veto Override DTO ───────────────────────────────────────────────────── + +export class VetoOverrideDto { + @IsString() + @MinLength(10) + @MaxLength(1000) + justification!: string; // PM ต้องระบุเหตุผลที่ชัดเจน (min 10 chars) +} diff --git a/backend/src/modules/review-team/entities/review-task.entity.ts b/backend/src/modules/review-team/entities/review-task.entity.ts new file mode 100644 index 00000000..8a95d770 --- /dev/null +++ b/backend/src/modules/review-team/entities/review-task.entity.ts @@ -0,0 +1,98 @@ +// File: src/modules/review-team/entities/review-task.entity.ts +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + VersionColumn, +} from 'typeorm'; +import { Exclude } from 'class-transformer'; +import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity'; +import { ReviewTeam } from './review-team.entity'; +import { ResponseCode } from '../../response-code/entities/response-code.entity'; +import { User } from '../../user/entities/user.entity'; +import { Discipline } from '../../master/entities/discipline.entity'; +import { ReviewTaskStatus } from '../../common/enums/review.enums'; + +@Entity('review_tasks') +export class ReviewTask extends UuidBaseEntity { + @PrimaryGeneratedColumn() + @Exclude() + id!: number; + + @Column({ name: 'rfa_revision_id' }) + @Exclude() + rfaRevisionId!: number; + + @Column({ name: 'team_id' }) + @Exclude() + teamId!: number; + + @Column({ name: 'discipline_id' }) + @Exclude() + disciplineId!: number; + + @Column({ name: 'assigned_to_user_id', nullable: true }) + @Exclude() + assignedToUserId?: number; // NULL = auto-assign ตาม discipline + + @Column({ + type: 'enum', + enum: ReviewTaskStatus, + default: ReviewTaskStatus.PENDING, + }) + status!: ReviewTaskStatus; + + @Column({ name: 'due_date', type: 'date', nullable: true }) + dueDate?: Date; + + @Column({ name: 'response_code_id', nullable: true }) + @Exclude() + responseCodeId?: number; + + @Column({ type: 'text', nullable: true }) + comments?: string; + + @Column({ type: 'json', nullable: true }) + attachments?: string[]; // Array ของ attachment publicIds + + @Column({ name: 'delegated_from_user_id', nullable: true }) + @Exclude() + delegatedFromUserId?: number; // ติดตาม delegation chain + + @Column({ name: 'completed_at', type: 'timestamp', nullable: true }) + completedAt?: Date; + + @VersionColumn() + version!: number; // Optimistic locking (ADR-002) + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt!: Date; + + // Relations + @ManyToOne(() => ReviewTeam) + @JoinColumn({ name: 'team_id' }) + team?: ReviewTeam; + + @ManyToOne(() => Discipline) + @JoinColumn({ name: 'discipline_id' }) + discipline?: Discipline; + + @ManyToOne(() => User) + @JoinColumn({ name: 'assigned_to_user_id' }) + assignedToUser?: User; + + @ManyToOne(() => ResponseCode) + @JoinColumn({ name: 'response_code_id' }) + responseCode?: ResponseCode; + + @ManyToOne(() => User) + @JoinColumn({ name: 'delegated_from_user_id' }) + delegatedFromUser?: User; +} diff --git a/backend/src/modules/review-team/entities/review-team-member.entity.ts b/backend/src/modules/review-team/entities/review-team-member.entity.ts new file mode 100644 index 00000000..73cea13f --- /dev/null +++ b/backend/src/modules/review-team/entities/review-team-member.entity.ts @@ -0,0 +1,60 @@ +// File: src/modules/review-team/entities/review-team-member.entity.ts +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + 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 { Discipline } from '../../master/entities/discipline.entity'; +import { ReviewTeam } from './review-team.entity'; +import { ReviewTeamMemberRole } from '../../common/enums/review.enums'; + +@Entity('review_team_members') +export class ReviewTeamMember extends UuidBaseEntity { + @PrimaryGeneratedColumn() + @Exclude() + id!: number; + + @Column({ name: 'team_id' }) + @Exclude() + teamId!: number; + + @Column({ name: 'user_id' }) + @Exclude() + userId!: number; + + @Column({ name: 'discipline_id' }) + @Exclude() + disciplineId!: number; + + @Column({ + type: 'enum', + enum: ReviewTeamMemberRole, + default: ReviewTeamMemberRole.REVIEWER, + }) + role!: ReviewTeamMemberRole; + + @Column({ name: 'priority_order', default: 0 }) + priorityOrder!: number; // สำหรับ fallback sequential assignment + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; + + // Relations + @ManyToOne(() => ReviewTeam, (team: ReviewTeam) => team.members, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'team_id' }) + team!: ReviewTeam; + + @ManyToOne(() => User) + @JoinColumn({ name: 'user_id' }) + user?: User; + + @ManyToOne(() => Discipline) + @JoinColumn({ name: 'discipline_id' }) + discipline?: Discipline; +} diff --git a/backend/src/modules/review-team/entities/review-team.entity.ts b/backend/src/modules/review-team/entities/review-team.entity.ts new file mode 100644 index 00000000..0db7f66b --- /dev/null +++ b/backend/src/modules/review-team/entities/review-team.entity.ts @@ -0,0 +1,52 @@ +// File: src/modules/review-team/entities/review-team.entity.ts +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + OneToMany, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Exclude } from 'class-transformer'; +import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity'; +import { Project } from '../../project/entities/project.entity'; +import { ReviewTeamMember } from './review-team-member.entity'; + +@Entity('review_teams') +export class ReviewTeam extends UuidBaseEntity { + @PrimaryGeneratedColumn() + @Exclude() + id!: number; + + @Column({ name: 'project_id' }) + @Exclude() + projectId!: number; + + @Column({ length: 100 }) + name!: string; + + @Column({ length: 255, nullable: true }) + description?: string; + + @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 }) + isActive!: boolean; + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt!: Date; + + // Relations + @ManyToOne(() => Project) + @JoinColumn({ name: 'project_id' }) + project?: Project; + + @OneToMany(() => ReviewTeamMember, (member: ReviewTeamMember) => member.team, { cascade: true }) + members?: ReviewTeamMember[]; +} diff --git a/backend/src/modules/review-team/review-task.service.ts b/backend/src/modules/review-team/review-task.service.ts new file mode 100644 index 00000000..09cef191 --- /dev/null +++ b/backend/src/modules/review-team/review-task.service.ts @@ -0,0 +1,179 @@ +// File: src/modules/review-team/review-task.service.ts +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'; +import { ResponseCode } from '../response-code/entities/response-code.entity'; +import { + CompleteReviewTaskDto, + SearchReviewTaskDto, +} from './dto/shared/review-team.dto'; +import { ReviewTaskStatus } from '../common/enums/review.enums'; +import { validateTaskCompletionRequirements } from '../../common/validators/review-validators'; + +@Injectable() +export class ReviewTaskService { + private readonly logger = new Logger(ReviewTaskService.name); + + constructor( + @InjectRepository(ReviewTask) + private readonly reviewTaskRepo: Repository, + @InjectRepository(ResponseCode) + private readonly responseCodeRepo: Repository, + ) {} + + /** + * ดึง Tasks ทั้งหมดของ RFA Revision (internal use) + */ + async findByRevisionId(rfaRevisionId: number): Promise { + return this.reviewTaskRepo.find({ where: { rfaRevisionId } }); + } + + /** + * ค้นหา Review Tasks ตาม filter (FR-004) + */ + async findAll(dto: SearchReviewTaskDto): Promise { + const qb = this.reviewTaskRepo + .createQueryBuilder('task') + .leftJoinAndSelect('task.discipline', 'discipline') + .leftJoinAndSelect('task.assignedToUser', 'user') + .leftJoinAndSelect('task.responseCode', 'responseCode') + .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 }); + } + + if (dto.status) { + qb.andWhere('task.status = :status', { status: dto.status }); + } + + if (dto.assignedToUserPublicId) { + qb.andWhere('user.uuid = :userUuid', { userUuid: dto.assignedToUserPublicId }); + } + + if (dto.dueDateFrom) { + qb.andWhere('task.due_date >= :from', { from: dto.dueDateFrom }); + } + + if (dto.dueDateTo) { + qb.andWhere('task.due_date <= :to', { to: dto.dueDateTo }); + } + + return qb.orderBy('task.created_at', 'ASC').getMany(); + } + + /** + * ดึง Review Task ตาม publicId (ADR-019) + */ + async findByPublicId(publicId: string): Promise { + const task = await this.reviewTaskRepo.findOne({ + where: { publicId }, + relations: ['discipline', 'assignedToUser', 'responseCode', 'team'], + }); + + if (!task) { + throw new NotFoundException(`Review Task not found: ${publicId}`); + } + + return task; + } + + /** + * ดึง Tasks รวมทั้งหมดของ RFA Revision พร้อม Aggregate Status (FR-004) + */ + async getAggregateStatus(rfaRevisionId: number): Promise<{ + total: number; + completed: number; + pending: number; + summary: string; + }> { + const tasks = await this.reviewTaskRepo.find({ where: { rfaRevisionId } }); + + const total = tasks.length; + const completed = tasks.filter( + (t: ReviewTask) => + t.status === ReviewTaskStatus.COMPLETED || t.status === ReviewTaskStatus.CANCELLED, + ).length; + const pending = total - completed; + + return { + total, + completed, + pending, + summary: `${completed} of ${total} Disciplines Reviewed`, + }; + } + + /** + * เริ่มตรวจสอบ Review Task (เปลี่ยน status จาก PENDING → IN_PROGRESS) + */ + async startReview(publicId: string): Promise { + const task = await this.findByPublicId(publicId); + + if (task.status !== ReviewTaskStatus.PENDING) { + throw new BadRequestException( + `Cannot start review: task is already ${task.status}`, + ); + } + + task.status = ReviewTaskStatus.IN_PROGRESS; + return this.reviewTaskRepo.save(task); + } + + /** + * บันทึกผลการตรวจสอบ (FR-009, T069) + * ใช้ Optimistic Locking (@VersionColumn) ป้องกัน race condition (ADR-002) + */ + async completeReview(publicId: string, dto: CompleteReviewTaskDto): Promise { + const task = await this.findByPublicId(publicId); + + if ( + task.status === ReviewTaskStatus.COMPLETED || + task.status === ReviewTaskStatus.CANCELLED + ) { + throw new BadRequestException( + `Cannot complete review: task is already ${task.status}`, + ); + } + + // ตรวจสอบ Response Code (ADR-019) + const responseCode = await this.responseCodeRepo.findOne({ + where: { publicId: dto.responseCodePublicId }, + }); + + if (!responseCode) { + throw new NotFoundException( + `Response Code not found: ${dto.responseCodePublicId}`, + ); + } + + // Validate completion requirements (T073) + validateTaskCompletionRequirements( + ReviewTaskStatus.COMPLETED, + responseCode.id, + false, // requiresComments checked at controller level via ResponseCodeRule + dto.comments, + ); + + task.status = ReviewTaskStatus.COMPLETED; + task.responseCodeId = responseCode.id; + task.comments = dto.comments; + task.attachments = dto.attachmentPublicIds; + task.completedAt = new Date(); + + try { + // TypeORM จะ throw OptimisticLockVersionMismatchError ถ้า version ไม่ตรง (ADR-002) + 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')) { + throw new ConflictException( + 'Review task was modified concurrently. Please refresh and try again.', + ); + } + throw err; + } + } +} diff --git a/backend/src/modules/review-team/review-team.controller.ts b/backend/src/modules/review-team/review-team.controller.ts new file mode 100644 index 00000000..fcaa50c4 --- /dev/null +++ b/backend/src/modules/review-team/review-team.controller.ts @@ -0,0 +1,92 @@ +// File: src/modules/review-team/review-team.controller.ts +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + Query, + UseGuards, +} from '@nestjs/common'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { ReviewTeamService } from './review-team.service'; +import { + CreateReviewTeamDto, + UpdateReviewTeamDto, + AddTeamMemberDto, + SearchReviewTeamDto, +} from './dto/shared/review-team.dto'; + +@Controller('review-teams') +@UseGuards(JwtAuthGuard) +export class ReviewTeamController { + constructor(private readonly reviewTeamService: ReviewTeamService) {} + + /** + * GET /review-teams + * ดึงรายการ Review Teams ตาม project + */ + @Get() + findAll(@Query() dto: SearchReviewTeamDto) { + return this.reviewTeamService.findAll(dto); + } + + /** + * GET /review-teams/:publicId + * ดึง Review Team เดียว (ADR-019) + */ + @Get(':publicId') + findOne(@Param('publicId') publicId: string) { + return this.reviewTeamService.findByPublicId(publicId); + } + + /** + * POST /review-teams + * สร้าง Review Team ใหม่ + */ + @Post() + create(@Body() dto: CreateReviewTeamDto) { + return this.reviewTeamService.create(dto); + } + + /** + * PATCH /review-teams/:publicId + * อัปเดต Review Team + */ + @Patch(':publicId') + update(@Param('publicId') publicId: string, @Body() dto: UpdateReviewTeamDto) { + return this.reviewTeamService.update(publicId, dto); + } + + /** + * POST /review-teams/:publicId/members + * เพิ่มสมาชิก + */ + @Post(':publicId/members') + addMember(@Param('publicId') teamPublicId: string, @Body() dto: AddTeamMemberDto) { + return this.reviewTeamService.addMember(teamPublicId, dto); + } + + /** + * DELETE /review-teams/:publicId/members/:memberPublicId + * ลบสมาชิก + */ + @Delete(':publicId/members/:memberPublicId') + removeMember( + @Param('publicId') teamPublicId: string, + @Param('memberPublicId') memberPublicId: string, + ) { + return this.reviewTeamService.removeMember(teamPublicId, memberPublicId); + } + + /** + * DELETE /review-teams/:publicId + * Deactivate Review Team (soft delete) + */ + @Delete(':publicId') + deactivate(@Param('publicId') publicId: string) { + return this.reviewTeamService.deactivate(publicId); + } +} diff --git a/backend/src/modules/review-team/review-team.module.ts b/backend/src/modules/review-team/review-team.module.ts new file mode 100644 index 00000000..33b76bb1 --- /dev/null +++ b/backend/src/modules/review-team/review-team.module.ts @@ -0,0 +1,66 @@ +// File: src/modules/review-team/review-team.module.ts +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { BullModule } from '@nestjs/bullmq'; + +// Entities +import { ReviewTeam } from './entities/review-team.entity'; +import { ReviewTeamMember } from './entities/review-team-member.entity'; +import { ReviewTask } from './entities/review-task.entity'; + +// External entities needed for resolution +import { User } from '../user/entities/user.entity'; +import { Discipline } from '../master/entities/discipline.entity'; + +// Services +import { ReviewTeamService } from './review-team.service'; +import { ReviewTaskService } from './review-task.service'; +import { TaskCreationService } from './services/task-creation.service'; +import { AggregateStatusService } from './services/aggregate-status.service'; +import { ConsensusService } from './services/consensus.service'; +import { VetoOverrideService } from './services/veto-override.service'; + +// Controllers +import { ReviewTeamController } from './review-team.controller'; + +// Modules +import { ResponseCodeModule } from '../response-code/response-code.module'; +import { NotificationModule } from '../notification/notification.module'; +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'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ReviewTeam, ReviewTeamMember, ReviewTask, User, Discipline]), + BullModule.registerQueue( + { name: QUEUE_REMINDERS }, + { name: QUEUE_VETO_NOTIFICATIONS }, + ), + ResponseCodeModule, + NotificationModule, + UserModule, + DistributionModule, + ], + providers: [ + ReviewTeamService, + ReviewTaskService, + TaskCreationService, + AggregateStatusService, + ConsensusService, + VetoOverrideService, + ], + controllers: [ReviewTeamController], + exports: [ + ReviewTeamService, + ReviewTaskService, + TaskCreationService, + AggregateStatusService, + ConsensusService, + VetoOverrideService, + TypeOrmModule, + ], +}) +export class ReviewTeamModule {} diff --git a/backend/src/modules/review-team/review-team.service.ts b/backend/src/modules/review-team/review-team.service.ts new file mode 100644 index 00000000..b05b23a4 --- /dev/null +++ b/backend/src/modules/review-team/review-team.service.ts @@ -0,0 +1,188 @@ +// File: src/modules/review-team/review-team.service.ts +import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ReviewTeam } from './entities/review-team.entity'; +import { ReviewTeamMember } from './entities/review-team-member.entity'; +import { User } from '../user/entities/user.entity'; +import { Discipline } from '../master/entities/discipline.entity'; +import { + CreateReviewTeamDto, + UpdateReviewTeamDto, + AddTeamMemberDto, + SearchReviewTeamDto, +} from './dto/shared/review-team.dto'; + +@Injectable() +export class ReviewTeamService { + private readonly logger = new Logger(ReviewTeamService.name); + + constructor( + @InjectRepository(ReviewTeam) + private readonly teamRepo: Repository, + @InjectRepository(ReviewTeamMember) + private readonly memberRepo: Repository, + @InjectRepository(User) + private readonly userRepo: Repository, + @InjectRepository(Discipline) + private readonly disciplineRepo: Repository, + ) {} + + /** + * ดึง Review Teams ตาม project (FR-001) + */ + async findAll(dto: SearchReviewTeamDto): Promise { + const qb = this.teamRepo + .createQueryBuilder('team') + .leftJoinAndSelect('team.members', 'member') + .leftJoinAndSelect('member.user', 'user') + .leftJoinAndSelect('member.discipline', 'discipline'); + + if (dto.projectPublicId) { + qb.innerJoin('team.project', 'project').where('project.uuid = :uuid', { + uuid: dto.projectPublicId, + }); + } + + if (dto.isActive !== undefined) { + qb.andWhere('team.is_active = :isActive', { isActive: dto.isActive }); + } + + if (dto.search) { + qb.andWhere('team.name LIKE :search', { search: `%${dto.search}%` }); + } + + return qb.orderBy('team.created_at', 'DESC').getMany(); + } + + /** + * ดึง Review Team เดียวตาม publicId (ADR-019) + */ + async findByPublicId(publicId: string): Promise { + const team = await this.teamRepo.findOne({ + where: { publicId }, + relations: ['members', 'members.user', 'members.discipline', 'project'], + }); + + if (!team) { + throw new NotFoundException(`Review Team not found: ${publicId}`); + } + + return team; + } + + /** + * ดึง Teams ที่เป็น Default สำหรับ RFA type นั้นๆ (FR-002) + */ + async findDefaultForRfaType(rfaTypeCode: string, projectId: number): Promise { + const teams = await this.teamRepo.find({ + where: { projectId, isActive: true }, + relations: ['members'], + }); + + return teams.filter( + (t: ReviewTeam) => t.defaultForRfaTypes?.includes(rfaTypeCode) ?? false, + ); + } + + /** + * สร้าง Review Team ใหม่ + */ + async create(dto: CreateReviewTeamDto): Promise { + // ตรวจสอบว่า project มีอยู่จริง (via publicId) + const project = await this.teamRepo.manager.getRepository('projects').findOne({ + where: { uuid: dto.projectPublicId } as Record, + }); + + if (!project) { + throw new NotFoundException(`Project not found: ${dto.projectPublicId}`); + } + + const team = this.teamRepo.create({ + name: dto.name, + description: dto.description, + projectId: (project as { id: number }).id, + defaultForRfaTypes: dto.defaultForRfaTypes, + isActive: true, + }); + + return this.teamRepo.save(team); + } + + /** + * อัปเดต Review Team + */ + async update(publicId: string, dto: UpdateReviewTeamDto): Promise { + 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.isActive !== undefined) team.isActive = dto.isActive; + + return this.teamRepo.save(team); + } + + /** + * เพิ่มสมาชิกใน Review Team (FR-001) + */ + async addMember(teamPublicId: string, dto: AddTeamMemberDto): Promise { + 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}`); + + // ตรวจสอบ Discipline + const discipline = await this.disciplineRepo.findOne({ + where: { id: Number(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 }, + }); + + if (existing) { + throw new BadRequestException( + `User ${dto.userPublicId} is already a member of this team for discipline ${dto.disciplinePublicId}`, + ); + } + + const member = this.memberRepo.create({ + teamId: team.id, + userId: user.user_id, + disciplineId: discipline.id, + role: dto.role, + priorityOrder: dto.priorityOrder ?? 0, + }); + + return this.memberRepo.save(member); + } + + /** + * ลบสมาชิกออกจาก Review Team + */ + async removeMember(teamPublicId: string, memberPublicId: string): Promise { + const team = await this.findByPublicId(teamPublicId); + const member = await this.memberRepo.findOne({ + where: { publicId: memberPublicId, teamId: team.id }, + }); + + if (!member) { + throw new NotFoundException(`Member not found: ${memberPublicId}`); + } + + await this.memberRepo.remove(member); + } + + /** + * ลบ Review Team (soft delete ด้วย isActive = false) + */ + async deactivate(publicId: string): Promise { + const team = await this.findByPublicId(publicId); + team.isActive = false; + await this.teamRepo.save(team); + } +} diff --git a/backend/src/modules/review-team/services/aggregate-status.service.ts b/backend/src/modules/review-team/services/aggregate-status.service.ts new file mode 100644 index 00000000..7851bb0c --- /dev/null +++ b/backend/src/modules/review-team/services/aggregate-status.service.ts @@ -0,0 +1,107 @@ +// File: src/modules/review-team/services/aggregate-status.service.ts +// คำนวณสถานะรวมของ Review Tasks ภายใน RFA Revision (T067, FR-009) +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'; + +export interface AggregateStatus { + total: number; + completed: number; + pending: number; + inProgress: number; + delegated: number; + expired: number; + completionPct: number; + isAllComplete: boolean; + hasExpired: boolean; +} + +@Injectable() +export class AggregateStatusService { + private readonly logger = new Logger(AggregateStatusService.name); + + constructor( + @InjectRepository(ReviewTask) + private readonly taskRepo: Repository, + ) {} + + /** + * คำนวณสถานะรวมของทุก Review Tasks ใน RFA Revision (FR-009) + */ + async getForRevision(rfaRevisionId: number): Promise { + const tasks = await this.taskRepo.find({ + where: { rfaRevisionId }, + select: ['id', 'status'], + }); + + const counts = { + total: tasks.length, + completed: 0, + pending: 0, + inProgress: 0, + delegated: 0, + expired: 0, + }; + + 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; + } + } + + const completionPct = + counts.total > 0 + ? Math.round((counts.completed / counts.total) * 100) + : 0; + + return { + ...counts, + completionPct, + isAllComplete: counts.total > 0 && counts.completed === counts.total, + hasExpired: counts.expired > 0, + }; + } + + /** + * ตรวจสอบว่า RFA Revision พร้อมสำหรับ consensus หรือยัง (FR-010) + */ + async isReadyForConsensus(rfaRevisionId: number): Promise { + const status = await this.getForRevision(rfaRevisionId); + return status.isAllComplete; + } + + /** + * Determine consensus based on response codes of completed tasks (FR-010) + */ + async evaluateConsensus(rfaRevisionId: number): Promise { + const tasks = await this.taskRepo.find({ + where: { rfaRevisionId, status: ReviewTaskStatus.COMPLETED }, + relations: ['responseCode'], + }); + + if (tasks.length === 0) return ConsensusDecision.PENDING; + + // Veto check: any Code 3 = REJECTED + const hasVeto = tasks.some((t) => t.responseCode?.code === '3'); + if (hasVeto) return ConsensusDecision.REJECTED; + + // All approved: Code 1A or 1B = APPROVED + const allApproved = tasks.every((t) => + ['1A', '1B'].includes(t.responseCode?.code ?? ''), + ); + if (allApproved) return ConsensusDecision.APPROVED; + + // Any Code 2 = APPROVED_WITH_COMMENTS + const hasComments = tasks.some((t) => t.responseCode?.code === '2'); + if (hasComments) return ConsensusDecision.APPROVED_WITH_COMMENTS; + + return ConsensusDecision.APPROVED_WITH_COMMENTS; + } +} diff --git a/backend/src/modules/review-team/services/consensus.service.ts b/backend/src/modules/review-team/services/consensus.service.ts new file mode 100644 index 00000000..e1035996 --- /dev/null +++ b/backend/src/modules/review-team/services/consensus.service.ts @@ -0,0 +1,95 @@ +// File: src/modules/review-team/services/consensus.service.ts +// Evaluate parallel review consensus และ trigger distribution (T068, FR-010) +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +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'; + +export interface ConsensusResult { + decision: ConsensusDecision; + completedTasks: number; + totalTasks: number; + triggeredDistribution: boolean; +} + +@Injectable() +export class ConsensusService { + private readonly logger = new Logger(ConsensusService.name); + + constructor( + @InjectRepository(ReviewTask) + private readonly taskRepo: Repository, + private readonly aggregateStatusService: AggregateStatusService, + private readonly approvalListenerService: ApprovalListenerService, + ) {} + + /** + * เรียกหลัง task complete — ตรวจสอบ consensus และ trigger distribution (FR-010) + */ + async evaluateAfterTaskComplete( + rfaRevisionId: number, + context: { + rfaPublicId: string; + rfaRevisionPublicId: string; + projectId: number; + documentTypeCode: string; + }, + ): Promise { + const isReady = await this.aggregateStatusService.isReadyForConsensus(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`, + ); + return { + decision: ConsensusDecision.PENDING, + completedTasks: status.completed, + totalTasks: status.total, + triggeredDistribution: false, + }; + } + + const decision = await this.aggregateStatusService.evaluateConsensus(rfaRevisionId); + + this.logger.log( + `Revision ${rfaRevisionId}: consensus = ${decision} (${status.total} tasks)`, + ); + + let triggeredDistribution = false; + if ( + decision === ConsensusDecision.APPROVED || + decision === ConsensusDecision.APPROVED_WITH_COMMENTS + ) { + // ดึง response code ที่ predominant + const completedTasks = await this.taskRepo.find({ + where: { rfaRevisionId, status: ReviewTaskStatus.COMPLETED }, + relations: ['responseCode'], + order: { completedAt: 'DESC' }, + take: 1, + }); + + const responseCode = completedTasks[0]?.responseCode?.code ?? '1A'; + + await this.approvalListenerService.onConsensusReached({ + ...context, + responseCode, + decision, + approvedAt: new Date(), + }); + + triggeredDistribution = true; + } + + return { + decision, + completedTasks: status.completed, + totalTasks: status.total, + triggeredDistribution, + }; + } +} diff --git a/backend/src/modules/review-team/services/task-creation.service.ts b/backend/src/modules/review-team/services/task-creation.service.ts new file mode 100644 index 00000000..e1e84a6b --- /dev/null +++ b/backend/src/modules/review-team/services/task-creation.service.ts @@ -0,0 +1,112 @@ +// File: src/modules/review-team/services/task-creation.service.ts +// Strangler Pattern: แยก logic การสร้าง Parallel Review Tasks ออกจาก rfa.service.ts +// เรียกใช้หลังจาก RFA Submit สำเร็จ (T017 integration) + +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +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'; + +@Injectable() +export class TaskCreationService { + private readonly logger = new Logger(TaskCreationService.name); + + constructor( + @InjectRepository(ReviewTeam) + private readonly reviewTeamRepo: Repository, + @InjectRepository(ReviewTeamMember) + private readonly memberRepo: Repository, + @InjectRepository(ReviewTask) + private readonly reviewTaskRepo: Repository, + ) {} + + /** + * สร้าง Parallel Review Tasks สำหรับแต่ละ Discipline ใน Review Team (FR-003) + * เรียกใช้ภายใน Transaction ของ rfa.service.ts submit method + * + * @param rfaRevisionId - Internal ID ของ RFA Revision + * @param reviewTeamPublicId - publicId ของ Review Team (ADR-019) + * @param dueDate - กำหนดเวลาตรวจสอบ + * @param manager - EntityManager จาก QueryRunner (ใช้ Transaction เดิม) + */ + async createParallelTasks( + rfaRevisionId: number, + reviewTeamPublicId: string, + dueDate: Date, + manager: EntityManager, + ): Promise { + // ดึง ReviewTeam พร้อม members + const team = await this.reviewTeamRepo.findOne({ + where: { publicId: reviewTeamPublicId }, + relations: ['members'], + }); + + if (!team || !team.isActive) { + this.logger.warn( + `ReviewTeam ${reviewTeamPublicId} not found or inactive — skipping task creation`, + ); + return []; + } + + const members = team.members ?? []; + + if (members.length === 0) { + this.logger.warn( + `ReviewTeam ${reviewTeamPublicId} has no members — skipping task creation`, + ); + return []; + } + + // กลุ่ม members ตาม disciplineId (แต่ละ Discipline ต้องการเพียง 1 Task) + const disciplineMap = new Map(); + for (const member of members) { + // LEAD มี priority สูงสุด ถ้ามีหลายคนใน Discipline เดียวกัน + const existing = disciplineMap.get(member.disciplineId); + if (!existing || member.role === 'LEAD') { + disciplineMap.set(member.disciplineId, member); + } + } + + const tasks: ReviewTask[] = []; + + // สร้าง ReviewTask สำหรับแต่ละ Discipline พร้อมกัน (Parallel) + for (const [disciplineId, leadMember] of disciplineMap) { + const task = manager.create(ReviewTask, { + rfaRevisionId, + teamId: team.id, + disciplineId, + assignedToUserId: leadMember.userId, + status: ReviewTaskStatus.PENDING, + dueDate, + }); + const saved = await manager.save(ReviewTask, task); + tasks.push(saved); + } + + this.logger.log( + `Created ${tasks.length} parallel review tasks for RFA revision ${rfaRevisionId}, team ${reviewTeamPublicId}`, + ); + + return tasks; + } + + /** + * ตรวจสอบว่า RFA Revision มี Review Tasks ครบทุก Discipline แล้วหรือยัง + */ + async areAllTasksCompleted(rfaRevisionId: number): Promise { + const tasks = await this.reviewTaskRepo.find({ + where: { rfaRevisionId }, + }); + + if (tasks.length === 0) return false; + + return tasks.every( + (t: ReviewTask) => + t.status === ReviewTaskStatus.COMPLETED || + t.status === ReviewTaskStatus.CANCELLED, + ); + } +} diff --git a/backend/src/modules/review-team/services/veto-override.service.ts b/backend/src/modules/review-team/services/veto-override.service.ts new file mode 100644 index 00000000..b87d28a7 --- /dev/null +++ b/backend/src/modules/review-team/services/veto-override.service.ts @@ -0,0 +1,70 @@ +// 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 { 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'; + +export interface VetoOverrideDto { + rfaRevisionId: number; + rfaPublicId: string; + rfaRevisionPublicId: string; + projectId: number; + documentTypeCode: string; + overrideReason: string; + overriddenByUserId: number; +} + +@Injectable() +export class VetoOverrideService { + private readonly logger = new Logger(VetoOverrideService.name); + + constructor( + @InjectRepository(ReviewTask) + private readonly taskRepo: Repository, + private readonly approvalListenerService: ApprovalListenerService, + private readonly dataSource: DataSource, + ) {} + + /** + * PM Override: บังคับ APPROVED แม้ว่าจะมี Code 3 rejection (FR-012) + * ต้องมี justification reason และ audit trail + */ + 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}`); + } + + const hasVeto = tasks.some((t) => t.responseCode?.code === '3'); + if (!hasVeto) { + 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'); + } + + this.logger.warn( + `PM Override executed by user ${dto.overriddenByUserId} for revision ${dto.rfaRevisionId}. Reason: ${dto.overrideReason}`, + ); + + await this.approvalListenerService.onConsensusReached({ + rfaPublicId: dto.rfaPublicId, + rfaRevisionPublicId: dto.rfaRevisionPublicId, + projectId: dto.projectId, + documentTypeCode: dto.documentTypeCode, + responseCode: '1A', + decision: ConsensusDecision.OVERRIDDEN, + approvedAt: new Date(), + }); + + return { decision: ConsensusDecision.OVERRIDDEN }; + } +} diff --git a/backend/src/modules/rfa/dto/submit-rfa.dto.ts b/backend/src/modules/rfa/dto/submit-rfa.dto.ts index 392c5d90..e8374b9f 100644 --- a/backend/src/modules/rfa/dto/submit-rfa.dto.ts +++ b/backend/src/modules/rfa/dto/submit-rfa.dto.ts @@ -1,6 +1,6 @@ // File: src/modules/rfa/dto/submit-rfa.dto.ts import { ApiProperty } from '@nestjs/swagger'; -import { IsInt, IsNotEmpty } from 'class-validator'; +import { IsInt, IsNotEmpty, IsOptional, IsUUID } from 'class-validator'; export class SubmitRfaDto { @ApiProperty({ @@ -10,4 +10,13 @@ export class SubmitRfaDto { @IsInt() @IsNotEmpty() templateId!: number; + + @ApiProperty({ + description: 'publicId ของ Review Team สำหรับ Parallel Review (ADR-019)', + example: '019505a1-7c3e-7000-8000-abc123def456', + required: false, + }) + @IsOptional() + @IsUUID() + reviewTeamPublicId?: string; } diff --git a/backend/src/modules/workflow-engine/dsl/parallel-gateway.handler.ts b/backend/src/modules/workflow-engine/dsl/parallel-gateway.handler.ts new file mode 100644 index 00000000..b0355aa6 --- /dev/null +++ b/backend/src/modules/workflow-engine/dsl/parallel-gateway.handler.ts @@ -0,0 +1,85 @@ +// File: src/modules/workflow-engine/dsl/parallel-gateway.handler.ts +// Parallel Gateway DSL handler สำหรับ RFA parallel review (T066, ADR-001) +// Strangler Pattern: ขยาย WorkflowEngine โดยไม่แก้ไข core DSL +import { Injectable, Logger } from '@nestjs/common'; + +export interface ParallelGatewayStep { + type: 'parallel_gateway'; + id: string; + branches: ParallelBranch[]; + completionStrategy: 'ALL' | 'MAJORITY' | 'ANY'; + onComplete: string; // next step ID +} + +export interface ParallelBranch { + id: string; + assigneeType: 'DISCIPLINE' | 'USER' | 'TEAM'; + assigneeId: string; // publicId + steps: string[]; // step IDs within this branch +} + +export interface GatewayExecutionContext { + rfaRevisionPublicId: string; + completedBranches: Set; + totalBranches: number; +} + +@Injectable() +export class ParallelGatewayHandler { + private readonly logger = new Logger(ParallelGatewayHandler.name); + + /** + * ตรวจสอบว่า gateway สามารถเดินหน้าได้หรือยัง ตาม completionStrategy (FR-008) + */ + canAdvance(step: ParallelGatewayStep, ctx: GatewayExecutionContext): boolean { + const { completedBranches, totalBranches } = ctx; + + switch (step.completionStrategy) { + case 'ALL': + return completedBranches.size === totalBranches; + + case 'MAJORITY': + return completedBranches.size > Math.floor(totalBranches / 2); + + case 'ANY': + return completedBranches.size >= 1; + + default: + this.logger.warn(`Unknown completion strategy: ${step.completionStrategy as string}`); + return false; + } + } + + /** + * สร้าง execution context จาก gateway definition + */ + createContext( + rfaRevisionPublicId: string, + step: ParallelGatewayStep, + ): GatewayExecutionContext { + return { + rfaRevisionPublicId, + completedBranches: new Set(), + totalBranches: step.branches.length, + }; + } + + /** + * Mark a branch complete and check if gateway can advance + */ + markBranchComplete( + ctx: GatewayExecutionContext, + branchId: string, + step: ParallelGatewayStep, + ): { canAdvance: boolean; completedCount: number } { + ctx.completedBranches.add(branchId); + + const canAdvance = this.canAdvance(step, ctx); + + this.logger.log( + `Branch ${branchId} complete. ${ctx.completedBranches.size}/${ctx.totalBranches} — canAdvance: ${canAdvance}`, + ); + + return { canAdvance, completedCount: ctx.completedBranches.size }; + } +} diff --git a/backend/tests/e2e/rfa-workflow.e2e-spec.ts b/backend/tests/e2e/rfa-workflow.e2e-spec.ts new file mode 100644 index 00000000..b4e7ae79 --- /dev/null +++ b/backend/tests/e2e/rfa-workflow.e2e-spec.ts @@ -0,0 +1,38 @@ +// File: tests/e2e/rfa-workflow.e2e-spec.ts +// E2E test ครอบคลุม RFA Approval Refactor full workflow (T077) +// TODO: ต้องมี test database + seeded data สำหรับ E2E run จริง + +/** + * E2E Workflow Coverage: + * 1. RFA submit → Review Tasks created (parallel) + * 2. All reviewers complete → Consensus evaluated + * 3. Consensus APPROVED → Distribution queued + * 4. Distribution processed → Transmittal created + * 5. Veto (Code 3) → PM override → force APPROVED + * 6. Reminder sent when task overdue + * 7. Delegation: delegate completes task on behalf + */ + +describe('RFA Approval Workflow (E2E)', () => { + // TODO: Bootstrap NestJS test app + seed test data + + describe('Phase 1-3: Submit → Parallel Review → Consensus', () => { + it.todo('should create parallel review tasks on RFA submit'); + it.todo('should evaluate APPROVED consensus when all Code 1A'); + it.todo('should evaluate REJECTED consensus when any Code 3'); + it.todo('should allow PM override of Code 3 veto'); + }); + + describe('Phase 4-5: Delegation → Reminder', () => { + it.todo('should delegate review task to another user'); + it.todo('should block circular delegation'); + it.todo('should send reminder when task is overdue'); + it.todo('should escalate to L2 after 3 days overdue'); + }); + + describe('Phase 6-7: Distribution', () => { + it.todo('should queue distribution after APPROVED consensus'); + it.todo('should create Transmittal records from distribution matrix'); + it.todo('should skip distribution for REJECTED'); + }); +}); diff --git a/backend/tests/integration/review-team/parallel-review.spec.ts b/backend/tests/integration/review-team/parallel-review.spec.ts new file mode 100644 index 00000000..9727ac68 --- /dev/null +++ b/backend/tests/integration/review-team/parallel-review.spec.ts @@ -0,0 +1,49 @@ +// File: tests/integration/review-team/parallel-review.spec.ts +// Integration tests สำหรับ Parallel Review consensus flow (T076) +// TODO: ขยาย test suite เมื่อ test database พร้อม (Sprint ถัดไป) + +import { ConsensusDecision } from '../../../src/modules/common/enums/review.enums'; + +describe('Parallel Review Consensus (Integration)', () => { + describe('Consensus evaluation', () => { + it('should return APPROVED when all tasks have Code 1A', () => { + const codes = ['1A', '1A', '1A']; + const hasVeto = codes.some((c) => c === '3'); + const allApproved = codes.every((c) => ['1A', '1B'].includes(c)); + + const decision = hasVeto + ? ConsensusDecision.REJECTED + : allApproved + ? ConsensusDecision.APPROVED + : ConsensusDecision.APPROVED_WITH_COMMENTS; + + expect(decision).toBe(ConsensusDecision.APPROVED); + }); + + it('should return REJECTED when any task has Code 3', () => { + const codes = ['1A', '3', '2']; + const hasVeto = codes.some((c) => c === '3'); + + const decision = hasVeto ? ConsensusDecision.REJECTED : ConsensusDecision.APPROVED; + + expect(decision).toBe(ConsensusDecision.REJECTED); + }); + + it('should return APPROVED_WITH_COMMENTS when mix of 1A and 2', () => { + const codes = ['1A', '2', '1B']; + const hasVeto = codes.some((c) => c === '3'); + const allApproved = codes.every((c) => ['1A', '1B'].includes(c)); + const hasComments = codes.some((c) => c === '2'); + + const decision = hasVeto + ? ConsensusDecision.REJECTED + : allApproved + ? ConsensusDecision.APPROVED + : hasComments + ? ConsensusDecision.APPROVED_WITH_COMMENTS + : ConsensusDecision.PENDING; + + expect(decision).toBe(ConsensusDecision.APPROVED_WITH_COMMENTS); + }); + }); +}); diff --git a/backend/tests/unit/delegation/circular-detection.service.spec.ts b/backend/tests/unit/delegation/circular-detection.service.spec.ts new file mode 100644 index 00000000..d461e0a6 --- /dev/null +++ b/backend/tests/unit/delegation/circular-detection.service.spec.ts @@ -0,0 +1,69 @@ +// File: tests/unit/delegation/circular-detection.service.spec.ts +// Unit tests สำหรับ CircularDetectionService — ป้องกัน delegation loops (T075) +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { CircularDetectionService } from '../../../src/modules/delegation/services/circular-detection.service'; +import { Delegation } from '../../../src/modules/delegation/entities/delegation.entity'; + +const mockDelegationRepo = { + find: jest.fn(), +}; + +describe('CircularDetectionService', () => { + let service: CircularDetectionService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CircularDetectionService, + { provide: getRepositoryToken(Delegation), useValue: mockDelegationRepo }, + ], + }).compile(); + + service = module.get(CircularDetectionService); + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('wouldCreateCircle', () => { + it('should return false when no delegations exist', async () => { + mockDelegationRepo.find.mockResolvedValue([]); + const result = await service.wouldCreateCircle(1, 2); + expect(result).toBe(false); + }); + + it('should detect direct circular delegation A→B when B→A exists', async () => { + // B (id=2) delegates to A (id=1) + mockDelegationRepo.find.mockResolvedValue([ + { delegatorId: 2, delegateId: 1 }, + ]); + // Now trying to add A→B — would create cycle + const result = await service.wouldCreateCircle(1, 2); + expect(result).toBe(true); + }); + + it('should detect indirect cycle A→B→C when trying C→A', async () => { + // A→B and B→C already exist + mockDelegationRepo.find.mockResolvedValue([ + { delegatorId: 1, delegateId: 2 }, + { delegatorId: 2, delegateId: 3 }, + ]); + // Now trying C→A — would create A→B→C→A cycle + const result = await service.wouldCreateCircle(3, 1); + expect(result).toBe(true); + }); + + it('should return false for non-circular delegations', async () => { + // A→B and B→C — adding D→A is fine + mockDelegationRepo.find.mockResolvedValue([ + { delegatorId: 1, delegateId: 2 }, + { delegatorId: 2, delegateId: 3 }, + ]); + const result = await service.wouldCreateCircle(4, 1); + expect(result).toBe(false); + }); + }); +}); diff --git a/backend/tests/unit/response-code/response-code.service.spec.ts b/backend/tests/unit/response-code/response-code.service.spec.ts new file mode 100644 index 00000000..fb114668 --- /dev/null +++ b/backend/tests/unit/response-code/response-code.service.spec.ts @@ -0,0 +1,67 @@ +// File: tests/unit/response-code/response-code.service.spec.ts +// Unit tests สำหรับ ResponseCodeService (T074) +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { ResponseCodeService } from '../../../src/modules/response-code/response-code.service'; +import { ResponseCode } from '../../../src/modules/response-code/entities/response-code.entity'; +import { ResponseCodeRule } from '../../../src/modules/response-code/entities/response-code-rule.entity'; +import { ResponseCodeCategory } from '../../../src/modules/common/enums/review.enums'; + +const mockCode: Partial = { + id: 1, + publicId: 'test-uuid-1', + code: '1A', + category: ResponseCodeCategory.ENGINEERING, + descriptionTh: 'ผ่าน — ไม่มีเงื่อนไข', + descriptionEn: 'Approved — No Comments', + isActive: true, + isSystem: true, +}; + +const mockCodeRepo = { + find: jest.fn().mockResolvedValue([mockCode]), + findOne: jest.fn().mockResolvedValue(mockCode), +}; + +const mockRuleRepo = { + find: jest.fn().mockResolvedValue([]), +}; + +describe('ResponseCodeService', () => { + let service: ResponseCodeService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ResponseCodeService, + { provide: getRepositoryToken(ResponseCode), useValue: mockCodeRepo }, + { provide: getRepositoryToken(ResponseCodeRule), useValue: mockRuleRepo }, + ], + }).compile(); + + service = module.get(ResponseCodeService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('findByCategory', () => { + it('should return codes filtered by category', async () => { + const result = await service.findByCategory(ResponseCodeCategory.ENGINEERING); + expect(mockCodeRepo.find).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ category: ResponseCodeCategory.ENGINEERING }), + }), + ); + expect(result).toEqual([mockCode]); + }); + }); + + describe('findByDocumentType', () => { + it('should return enabled codes for document type', async () => { + const result = await service.findByDocumentType(1, 1); + expect(result).toBeDefined(); + }); + }); +}); diff --git a/frontend/app/(dashboard)/settings/delegation/page.tsx b/frontend/app/(dashboard)/settings/delegation/page.tsx new file mode 100644 index 00000000..6c07f306 --- /dev/null +++ b/frontend/app/(dashboard)/settings/delegation/page.tsx @@ -0,0 +1,120 @@ +'use client'; + +// File: app/(dashboard)/settings/delegation/page.tsx +// หน้าจัดการ Delegation ของตัวเอง (FR-011) +import { useState } from 'react'; +import { Plus, ArrowRightLeft, Trash2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { useMyDelegations, useCreateDelegation, useRevokeDelegation, Delegation } from '@/hooks/use-delegation'; +import { DelegationForm } from '@/components/delegation/DelegationForm'; + +export default function DelegationPage() { + const [createOpen, setCreateOpen] = useState(false); + + const { data: delegations = [], isLoading } = useMyDelegations(); + const createDelegation = useCreateDelegation(); + const revokeDelegation = useRevokeDelegation(); + + const today = new Date().toISOString().split('T')[0]; + + return ( +
+
+
+

Delegation Settings

+

+ มอบหมายหน้าที่ตรวจสอบให้ผู้อื่นในช่วงที่ไม่อยู่ +

+
+ + + + + + + + Create Delegation + + + createDelegation.mutate(dto, { + onSuccess: () => setCreateOpen(false), + }) + } + isLoading={createDelegation.isPending} + /> + + +
+ + {isLoading && ( +
Loading delegations...
+ )} + +
+ {(delegations as Delegation[]).map((d: Delegation) => { + const isActive = + d.isActive && d.startDate <= today && d.endDate >= today; + const isPast = d.endDate < today; + + return ( + + +
+
+ + + → {d.delegate?.fullName ?? d.delegate?.email ?? '—'} + + {isActive && Active} + {isPast && Expired} + {!isActive && !isPast && ( + Scheduled + )} + {d.scope} +
+ {!isPast && d.isActive && ( + + )} +
+
+ +

+ {d.startDate} → {d.endDate} + {d.reason && ` • ${d.reason}`} +

+
+
+ ); + })} + + {!isLoading && delegations.length === 0 && ( +
+ +

No delegations set. Create one when you need a proxy reviewer.

+
+ )} +
+
+ ); +} diff --git a/frontend/app/(dashboard)/settings/review-teams/page.tsx b/frontend/app/(dashboard)/settings/review-teams/page.tsx new file mode 100644 index 00000000..c018f9b6 --- /dev/null +++ b/frontend/app/(dashboard)/settings/review-teams/page.tsx @@ -0,0 +1,167 @@ +'use client'; + +// File: app/(dashboard)/settings/review-teams/page.tsx +// หน้าจัดการ Review Teams (FR-001, FR-002) +import { useState } from 'react'; +import { Plus, Users, ChevronDown, ChevronUp } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { useReviewTeams, useCreateReviewTeam, useUpdateReviewTeam } from '@/hooks/use-review-teams'; +import { ReviewTeamForm } from '@/components/review-team/ReviewTeamForm'; +import { TeamMemberManager } from '@/components/review-team/TeamMemberManager'; +import { ReviewTeam } from '@/types/review-team'; + +// TODO: ดึง projectPublicId จาก context หรือ URL param จริง +const MOCK_PROJECT_ID = 'current-project-public-id'; + +export default function ReviewTeamsPage() { + const [expandedTeam, setExpandedTeam] = useState(null); + const [createOpen, setCreateOpen] = useState(false); + const [editTeam, setEditTeam] = useState(null); + + const { data: teams = [], isLoading } = useReviewTeams({ + projectPublicId: MOCK_PROJECT_ID, + }); + + const createTeam = useCreateReviewTeam(); + const updateTeam = useUpdateReviewTeam(); + + return ( +
+
+
+

Review Teams

+

+ จัดการทีมตรวจสอบแยกตาม Discipline สำหรับ Parallel Review +

+
+ + + + + + + + Create Review Team + + + createTeam.mutate(values, { + onSuccess: () => setCreateOpen(false), + }) + } + isLoading={createTeam.isPending} + /> + + +
+ + {isLoading && ( +
Loading teams...
+ )} + +
+ {(teams as ReviewTeam[]).map((team) => ( + + +
+
+ + {team.name} + {!team.isActive && ( + Inactive + )} + {(team.defaultForRfaTypes ?? []).map((type) => ( + + {type} + + ))} +
+
+ + +
+
+ {team.description && ( +

{team.description}

+ )} +
+ + {expandedTeam === team.publicId && ( + +
+ Members ({(team.members ?? []).length}) +
+ +
+ )} +
+ ))} + + {!isLoading && (teams as ReviewTeam[]).length === 0 && ( +
+ +

No Review Teams yet. Create one to enable Parallel Review.

+
+ )} +
+ + {/* Edit Dialog */} + setEditTeam(null)}> + + + Edit Review Team + + {editTeam && ( + + updateTeam.mutate( + { publicId: editTeam.publicId, data: values }, + { onSuccess: () => setEditTeam(null) }, + ) + } + isLoading={updateTeam.isPending} + /> + )} + + +
+ ); +} diff --git a/frontend/components/delegation/DelegationForm.tsx b/frontend/components/delegation/DelegationForm.tsx new file mode 100644 index 00000000..fc7f07b1 --- /dev/null +++ b/frontend/components/delegation/DelegationForm.tsx @@ -0,0 +1,178 @@ +'use client'; + +// File: components/delegation/DelegationForm.tsx +// Form สร้าง Delegation พร้อม date range picker (FR-011) +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Button } from '@/components/ui/button'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { CreateDelegationDto } from '@/hooks/use-delegation'; +import { DelegationScope } from '@/types/review-team'; + +const delegationSchema = z + .object({ + delegateUserPublicId: z.string().uuid('Select a valid user'), + scope: z.enum(['ALL', 'RFA_ONLY', 'CORRESPONDENCE_ONLY', 'SPECIFIC_TYPES'] as const), + startDate: z.string().min(1, 'Start date is required'), + endDate: z.string().min(1, 'End date is required'), + reason: z.string().max(500).optional(), + }) + .refine((d: { startDate: string; endDate: string }) => new Date(d.startDate) < new Date(d.endDate), { + message: 'End date must be after start date', + path: ['endDate'], + }); + +type DelegationFormValues = z.infer; + +interface User { + publicId: string; + fullName?: string; + email?: string; +} + +interface DelegationFormProps { + availableUsers: User[]; + onSubmit: (dto: CreateDelegationDto) => void; + isLoading?: boolean; +} + +const SCOPE_LABELS: Record = { + ALL: 'All Documents', + RFA_ONLY: 'RFA Only', + CORRESPONDENCE_ONLY: 'Correspondence Only', + SPECIFIC_TYPES: 'Specific Document Types', +}; + +export function DelegationForm({ availableUsers, onSubmit, isLoading }: DelegationFormProps) { + const form = useForm({ + resolver: zodResolver(delegationSchema), + defaultValues: { scope: 'ALL' }, + }); + + const handleSubmit = (values: DelegationFormValues) => { + onSubmit({ + ...values, + scope: values.scope as DelegationScope, + }); + }; + + return ( +
+ + ( + + Delegate To + + + + + + )} + /> + + ( + + Scope + + + + + + )} + /> + +
+ ( + + Start Date + + + + + + )} + /> + ( + + End Date + + + + + + )} + /> +
+ + ( + + Reason (Optional) + +