690514:2019 204-rfa-approval-refactor #01
CI / CD Pipeline / build (push) Successful in 6m1s
CI / CD Pipeline / deploy (push) Failing after 6m42s

This commit is contained in:
2026-05-14 20:19:21 +07:00
parent 07cc6d47b1
commit 0240d80da5
183 changed files with 20050 additions and 1017 deletions
+17
View File
@@ -0,0 +1,17 @@
// File: src/config/bullmq.config.ts
// Change Log:
// - 2026-05-13: Add BullMQ config registry for reminder and distribution queues.
import { registerAs } from '@nestjs/config';
export default registerAs('bullmq', () => ({
prefix: process.env.BULLMQ_QUEUE_PREFIX || 'rfa',
reminderQueue: process.env.BULLMQ_REMINDER_QUEUE || 'rfa-reminders',
distributionQueue:
process.env.BULLMQ_DISTRIBUTION_QUEUE || 'rfa-distribution',
connection: {
host: process.env.REDIS_HOST || 'cache',
port: Number(process.env.REDIS_PORT || '6379'),
password: process.env.REDIS_PASSWORD || undefined,
},
}));
+5
View File
@@ -0,0 +1,5 @@
// File: src/config/redis.config.ts
// Change Log:
// - 2026-05-13: Add task-path config wrapper for Redis settings used by BullMQ and Redlock.
export { default } from '../common/config/redis.config';
@@ -11,6 +11,7 @@ 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';
import { DelegationScope } from '../common/enums/review.enums';
@Injectable()
export class DelegationService {
@@ -63,6 +64,23 @@ export class DelegationService {
);
}
const delegateOnward = await this.findActiveDelegate(
delegate.user_id,
dto.startDate,
[
DelegationScope.ALL,
DelegationScope.RFA_ONLY,
DelegationScope.CORRESPONDENCE_ONLY,
DelegationScope.SPECIFIC_TYPES,
]
);
if (delegateOnward) {
throw new BadRequestException(
'Nested delegation is not allowed — delegatee already delegates onward'
);
}
const delegation = this.delegationRepo.create({
delegatorUserId: delegator.user_id,
delegateUserId: delegate.user_id,
@@ -98,7 +116,8 @@ export class DelegationService {
*/
async findActiveDelegate(
userId: number,
date: Date = new Date()
date: Date = new Date(),
scopes: DelegationScope[] = [DelegationScope.ALL]
): Promise<User | null> {
const delegation = await this.delegationRepo
.createQueryBuilder('d')
@@ -107,6 +126,7 @@ export class DelegationService {
.andWhere('d.is_active = 1')
.andWhere('d.start_date <= :date', { date })
.andWhere('d.end_date >= :date', { date })
.andWhere('d.scope IN (:...scopes)', { scopes })
.orderBy('d.created_at', 'DESC')
.getOne();
@@ -1,50 +1,62 @@
// File: src/modules/distribution/distribution-matrix.service.ts
// Change Log
// - 2026-05-14: Resolve public IDs internally and align CRUD with canonical schema.
// CRUD สำหรับ DistributionMatrix และ Recipients (T053)
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { IsNull, Repository } from 'typeorm';
import { validate as uuidValidate } from 'uuid';
import { DistributionMatrix } from './entities/distribution-matrix.entity';
import { DistributionRecipient } from './entities/distribution-recipient.entity';
import { Project } from '../project/entities/project.entity';
export interface CreateDistributionMatrixDto {
projectId: number;
documentTypeCode: string;
responseCodeFilter?: string[];
}
export interface AddRecipientDto {
recipientType: string;
recipientId?: number;
roleCode?: string;
deliveryMethod?: string;
isCc?: boolean;
}
import { ResponseCode } from '../response-code/entities/response-code.entity';
import { CreateDistributionMatrixDto } from './dto/create-distribution-matrix.dto';
import { UpdateDistributionMatrixDto } from './dto/update-distribution-matrix.dto';
import { AddDistributionRecipientDto } from './dto/add-distribution-recipient.dto';
@Injectable()
export class DistributionMatrixService {
private readonly logger = new Logger(DistributionMatrixService.name);
constructor(
@InjectRepository(DistributionMatrix)
private readonly matrixRepo: Repository<DistributionMatrix>,
@InjectRepository(DistributionRecipient)
private readonly recipientRepo: Repository<DistributionRecipient>,
@InjectRepository(Project)
private readonly projectRepo: Repository<Project>
private readonly projectRepo: Repository<Project>,
@InjectRepository(ResponseCode)
private readonly responseCodeRepo: Repository<ResponseCode>
) {}
async findByProject(projectId: number): Promise<DistributionMatrix[]> {
/**
* ดึง Matrix ของโครงการ พร้อม global defaults.
*/
async findByProject(projectId?: number): Promise<DistributionMatrix[]> {
return this.matrixRepo.find({
where: { projectId, isActive: true },
relations: ['recipients'],
order: { documentTypeCode: 'ASC' },
where:
projectId === undefined
? { isActive: true }
: [
{ projectId, isActive: true },
{ projectId: IsNull(), isActive: true },
],
relations: ['recipients', 'responseCode'],
order: { documentTypeId: 'ASC', createdAt: 'DESC' },
});
}
async findByProjectPublicId(
projectPublicId: string
projectPublicId?: string
): Promise<DistributionMatrix[]> {
if (!projectPublicId) return this.findByProject();
if (!uuidValidate(projectPublicId)) {
throw new BadRequestException(
`Invalid projectPublicId: ${projectPublicId}`
);
}
const project = await this.projectRepo.findOne({
where: { publicId: projectPublicId },
});
@@ -55,34 +67,64 @@ export class DistributionMatrixService {
async findOneByDocType(
projectId: number,
documentTypeCode: string
documentTypeId: number
): Promise<DistributionMatrix | null> {
return this.matrixRepo.findOne({
where: { projectId, documentTypeCode, isActive: true },
relations: ['recipients'],
where: [
{ projectId, documentTypeId, isActive: true },
{ projectId: IsNull(), documentTypeId, isActive: true },
],
relations: ['recipients', 'responseCode'],
});
}
async create(dto: CreateDistributionMatrixDto): Promise<DistributionMatrix> {
const matrix = this.matrixRepo.create(dto as Partial<DistributionMatrix>);
const matrix = this.matrixRepo.create({
name: dto.name,
projectId: await this.resolveProjectId(dto.projectPublicId),
documentTypeId: dto.documentTypeId,
responseCodeId: await this.resolveResponseCodeId(
dto.responseCodePublicId
),
conditions: dto.conditions,
isActive: true,
});
return this.matrixRepo.save(matrix);
}
async update(
publicId: string,
dto: UpdateDistributionMatrixDto
): Promise<DistributionMatrix> {
const matrix = await this.findMatrix(publicId);
if (dto.name !== undefined) matrix.name = dto.name;
if (dto.documentTypeId !== undefined) {
matrix.documentTypeId = dto.documentTypeId;
}
if (dto.projectPublicId !== undefined) {
matrix.projectId = await this.resolveProjectId(dto.projectPublicId);
}
if (dto.responseCodePublicId !== undefined) {
matrix.responseCodeId = await this.resolveResponseCodeId(
dto.responseCodePublicId
);
}
if (dto.conditions !== undefined) matrix.conditions = dto.conditions;
return this.matrixRepo.save(matrix);
}
async addRecipient(
matrixPublicId: string,
dto: AddRecipientDto
dto: AddDistributionRecipientDto
): Promise<DistributionRecipient> {
const matrix = await this.matrixRepo.findOne({
where: { publicId: matrixPublicId },
});
if (!matrix)
throw new NotFoundException(`Matrix not found: ${matrixPublicId}`);
const matrix = await this.findMatrix(matrixPublicId);
const recipient = this.recipientRepo.create({
matrixId: matrix.id,
...dto,
} as Partial<DistributionRecipient>);
recipientType: dto.recipientType,
recipientPublicId: dto.recipientPublicId,
deliveryMethod: dto.deliveryMethod,
sequence: dto.sequence,
});
return this.recipientRepo.save(recipient);
}
@@ -95,9 +137,44 @@ export class DistributionMatrixService {
}
async remove(publicId: string): Promise<void> {
const matrix = await this.matrixRepo.findOne({ where: { publicId } });
if (!matrix) throw new NotFoundException(publicId);
const matrix = await this.findMatrix(publicId);
matrix.isActive = false;
await this.matrixRepo.save(matrix);
}
private async findMatrix(publicId: string): Promise<DistributionMatrix> {
const matrix = await this.matrixRepo.findOne({ where: { publicId } });
if (!matrix) {
throw new NotFoundException(`Distribution Matrix not found: ${publicId}`);
}
return matrix;
}
private async resolveProjectId(
projectPublicId?: string
): Promise<number | undefined> {
if (!projectPublicId) return undefined;
const project = await this.projectRepo.findOne({
where: { publicId: projectPublicId },
});
if (!project) {
throw new NotFoundException(`Project not found: ${projectPublicId}`);
}
return project.id;
}
private async resolveResponseCodeId(
responseCodePublicId?: string
): Promise<number | undefined> {
if (!responseCodePublicId) return undefined;
const responseCode = await this.responseCodeRepo.findOne({
where: { publicId: responseCodePublicId },
});
if (!responseCode) {
throw new NotFoundException(
`Response Code not found: ${responseCodePublicId}`
);
}
return responseCode.id;
}
}
@@ -1,4 +1,6 @@
// File: src/modules/distribution/distribution.controller.ts
// Change Log
// - 2026-05-14: Add RBAC and validated public-ID DTOs for Distribution Matrix CRUD.
// Admin endpoints สำหรับจัดการ Distribution Matrix (T058)
import {
Controller,
@@ -9,57 +11,64 @@ import {
Param,
Query,
UseGuards,
Patch,
} from '@nestjs/common';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RbacGuard } from '../../common/guards/rbac.guard';
import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe';
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;
}
import { CreateDistributionMatrixDto } from './dto/create-distribution-matrix.dto';
import { AddDistributionRecipientDto } from './dto/add-distribution-recipient.dto';
import { UpdateDistributionMatrixDto } from './dto/update-distribution-matrix.dto';
@Controller('admin/distribution-matrices')
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, RbacGuard)
export class DistributionController {
constructor(private readonly matrixService: DistributionMatrixService) {}
@Get()
findByProject(
@Query('projectPublicId', ParseUuidPipe) projectPublicId: string
) {
findByProject(@Query('projectPublicId') projectPublicId?: string) {
return this.matrixService.findByProjectPublicId(projectPublicId);
}
@Post()
create(@Body() dto: CreateMatrixDto) {
@RequirePermission('master_data.manage')
create(@Body() dto: CreateDistributionMatrixDto) {
return this.matrixService.create(dto);
}
@Patch(':publicId')
@RequirePermission('master_data.manage')
update(
@Param('publicId', ParseUuidPipe) publicId: string,
@Body() dto: UpdateDistributionMatrixDto
) {
return this.matrixService.update(publicId, dto);
}
@Post(':publicId/recipients')
@RequirePermission('master_data.manage')
addRecipient(
@Param('publicId') publicId: string,
@Body() dto: AddRecipientDto
@Param('publicId', ParseUuidPipe) publicId: string,
@Body() dto: AddDistributionRecipientDto
) {
return this.matrixService.addRecipient(publicId, dto);
}
@Delete(':publicId/recipients/:recipientPublicId')
removeRecipient(@Param('recipientPublicId') recipientPublicId: string) {
return this.matrixService.removeRecipient(recipientPublicId);
@RequirePermission('master_data.manage')
async removeRecipient(
@Param('recipientPublicId', ParseUuidPipe) recipientPublicId: string
) {
await this.matrixService.removeRecipient(recipientPublicId);
return { success: true };
}
@Delete(':publicId')
remove(@Param('publicId') publicId: string) {
return this.matrixService.remove(publicId);
@RequirePermission('master_data.manage')
async remove(@Param('publicId', ParseUuidPipe) publicId: string) {
await this.matrixService.remove(publicId);
return { success: true };
}
}
@@ -1,4 +1,6 @@
// File: src/modules/distribution/distribution.module.ts
// Change Log
// - 2026-05-14: Register ResponseCode repository for Distribution Matrix publicId resolution.
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BullModule } from '@nestjs/bullmq';
@@ -13,6 +15,8 @@ import { TransmittalCreatorService } from './services/transmittal-creator.servic
import { QUEUE_DISTRIBUTION } from '../common/constants/queue.constants';
import { NotificationModule } from '../notification/notification.module';
import { Project } from '../project/entities/project.entity';
import { ResponseCode } from '../response-code/entities/response-code.entity';
import { DocumentNumberingModule } from '../document-numbering/document-numbering.module';
@Module({
imports: [
@@ -20,9 +24,11 @@ import { Project } from '../project/entities/project.entity';
DistributionMatrix,
DistributionRecipient,
Project,
ResponseCode,
]),
BullModule.registerQueue({ name: QUEUE_DISTRIBUTION }),
NotificationModule,
DocumentNumberingModule,
],
providers: [
DistributionService,
@@ -1,4 +1,6 @@
// File: src/modules/distribution/distribution.service.ts
// Change Log
// - 2026-05-14: Carry canonical documentTypeId in queue payload while preserving legacy code metadata.
// Enqueue distribution jobs เมื่อ RFA ได้รับการอนุมัติ (T054)
import { Injectable, Logger } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq';
@@ -9,7 +11,8 @@ export interface DistributionJobPayload {
rfaPublicId: string;
rfaRevisionPublicId: string;
projectId: number;
documentTypeCode: string;
documentTypeId?: number;
documentTypeCode?: string;
responseCode: string;
approvedAt: Date;
}
@@ -0,0 +1,21 @@
// File: src/modules/distribution/dto/add-distribution-recipient.dto.ts
// Change Log
// - 2026-05-14: Add validated DTO for polymorphic Distribution recipients.
import { IsEnum, IsInt, IsOptional, IsUUID } from 'class-validator';
import { DeliveryMethod, RecipientType } from '../../common/enums/review.enums';
export class AddDistributionRecipientDto {
@IsEnum(RecipientType)
recipientType!: RecipientType;
@IsUUID()
recipientPublicId!: string;
@IsOptional()
@IsEnum(DeliveryMethod)
deliveryMethod?: DeliveryMethod;
@IsOptional()
@IsInt()
sequence?: number;
}
@@ -0,0 +1,44 @@
// File: src/modules/distribution/dto/create-distribution-matrix.dto.ts
// Change Log
// - 2026-05-14: Add validated DTO for Distribution Matrix creation.
import {
IsInt,
IsOptional,
IsString,
IsUUID,
MaxLength,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
class DistributionConditionsDto {
@IsOptional()
@IsString({ each: true })
codes?: string[];
@IsOptional()
@IsString({ each: true })
excludeCodes?: string[];
}
export class CreateDistributionMatrixDto {
@IsString()
@MaxLength(100)
name!: string;
@IsOptional()
@IsUUID()
projectPublicId?: string;
@IsInt()
documentTypeId!: number;
@IsOptional()
@IsUUID()
responseCodePublicId?: string;
@IsOptional()
@ValidateNested()
@Type(() => DistributionConditionsDto)
conditions?: DistributionConditionsDto;
}
@@ -0,0 +1,9 @@
// File: src/modules/distribution/dto/update-distribution-matrix.dto.ts
// Change Log
// - 2026-05-14: Add validated DTO for Distribution Matrix updates.
import { PartialType } from '@nestjs/swagger';
import { CreateDistributionMatrixDto } from './create-distribution-matrix.dto';
export class UpdateDistributionMatrixDto extends PartialType(
CreateDistributionMatrixDto
) {}
@@ -1,10 +1,11 @@
// File: src/modules/distribution/entities/distribution-matrix.entity.ts
// Change Log
// - 2026-05-14: Align columns with canonical v1.9.0 schema and ADR-019 publicId contract.
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
ManyToOne,
JoinColumn,
@@ -13,6 +14,12 @@ 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';
import { ResponseCode } from '../../response-code/entities/response-code.entity';
export interface DistributionConditions {
codes?: string[];
excludeCodes?: string[];
}
@Entity('distribution_matrices')
export class DistributionMatrix extends UuidBaseEntity {
@@ -20,19 +27,23 @@ export class DistributionMatrix extends UuidBaseEntity {
@Exclude()
id!: number;
@Column({ name: 'project_id' })
@Column({ length: 100 })
name!: string;
@Column({ name: 'project_id', nullable: true })
@Exclude()
projectId!: number;
projectId?: number;
@Column({ name: 'document_type_code', length: 20 })
documentTypeCode!: string; // 'SDW', 'DDW', 'ADW', 'MS'...
@Column({ name: 'document_type_id' })
@Exclude()
documentTypeId!: number;
@Column({
name: 'response_code_filter',
type: 'simple-array',
nullable: true,
})
responseCodeFilter?: string[]; // ['1A','1B'] — NULL = ทุก code
@Column({ name: 'response_code_id', nullable: true })
@Exclude()
responseCodeId?: number;
@Column({ type: 'json', nullable: true })
conditions?: DistributionConditions;
@Column({ name: 'is_active', type: 'tinyint', default: 1 })
isActive!: boolean;
@@ -40,14 +51,14 @@ export class DistributionMatrix extends UuidBaseEntity {
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date;
// Relations
@ManyToOne(() => Project)
@JoinColumn({ name: 'project_id' })
project?: Project;
@ManyToOne(() => ResponseCode)
@JoinColumn({ name: 'response_code_id' })
responseCode?: ResponseCode;
@OneToMany(
() => DistributionRecipient,
(r: DistributionRecipient) => r.matrix,
@@ -1,4 +1,6 @@
// File: src/modules/distribution/entities/distribution-recipient.entity.ts
// Change Log
// - 2026-05-14: Store polymorphic recipient public IDs instead of internal numeric IDs.
import {
Entity,
PrimaryGeneratedColumn,
@@ -23,32 +25,29 @@ export class DistributionRecipient extends UuidBaseEntity {
matrixId!: number;
@Column({
name: 'recipient_type',
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({ name: 'recipient_public_id', type: 'uuid' })
recipientPublicId!: string;
@Column({
name: 'delivery_method',
type: 'enum',
enum: DeliveryMethod,
default: DeliveryMethod.BOTH,
})
deliveryMethod!: DeliveryMethod;
@Column({ name: 'is_cc', type: 'tinyint', default: 0 })
isCc!: boolean; // true = CC recipient, false = primary
@Column({ nullable: true })
sequence?: number;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
// Relations
@ManyToOne(
() => DistributionMatrix,
(m: DistributionMatrix) => m.recipients,
@@ -1,4 +1,6 @@
// File: src/modules/distribution/processors/distribution.processor.ts
// Change Log
// - 2026-05-14: Notify direct USER recipients after Distribution processing.
// BullMQ Worker สำหรับประมวลผล Distribution jobs (T056, ADR-008)
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Logger } from '@nestjs/common';
@@ -7,6 +9,7 @@ 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';
import { DeliveryMethod } from '../../common/enums/review.enums';
@Processor(QUEUE_DISTRIBUTION)
export class DistributionProcessor extends WorkerHost {
@@ -23,7 +26,7 @@ export class DistributionProcessor extends WorkerHost {
const payload = job.data;
this.logger.log(
`Processing distribution for RFA ${payload.rfaPublicId} (${payload.documentTypeCode}, code ${payload.responseCode})`
`Processing distribution for RFA ${payload.rfaPublicId} (${payload.documentTypeId ?? payload.documentTypeCode}, code ${payload.responseCode})`
);
// 1. สร้าง Transmittal records
@@ -34,7 +37,33 @@ export class DistributionProcessor extends WorkerHost {
`Created ${result.transmittalPublicIds.length} transmittals for RFA ${payload.rfaPublicId}`
);
// 2. แจ้งเตือน submitter
await Promise.all(
result.notificationTargets.flatMap((target) => {
const base = {
userId: target.userId,
title: `RFA ${payload.responseCode} distributed`,
message: `RFA ${payload.rfaPublicId} has been distributed after approval.`,
entityType: 'rfa',
link: `/rfa/${payload.rfaPublicId}`,
};
if (target.deliveryMethod === DeliveryMethod.BOTH) {
return [
this.notificationService.send({ ...base, type: 'SYSTEM' }),
this.notificationService.send({ ...base, type: 'EMAIL' }),
];
}
return [
this.notificationService.send({
...base,
type:
target.deliveryMethod === DeliveryMethod.EMAIL
? 'EMAIL'
: 'SYSTEM',
}),
];
})
);
this.logger.log(`Distribution complete for RFA ${payload.rfaPublicId}`);
}
}
@@ -1,4 +1,6 @@
// File: src/modules/distribution/services/approval-listener.service.ts
// Change Log
// - 2026-05-14: Accept canonical documentTypeId in approval events.
// Strangler Pattern — listens for RFA approval events and triggers distribution (T055)
import { Injectable, Logger } from '@nestjs/common';
import {
@@ -24,7 +26,8 @@ export class ApprovalListenerService {
rfaPublicId: string;
rfaRevisionPublicId: string;
projectId: number;
documentTypeCode: string;
documentTypeId?: number;
documentTypeCode?: string;
responseCode: string;
decision: ConsensusDecision;
approvedAt: Date;
@@ -45,6 +48,7 @@ export class ApprovalListenerService {
rfaPublicId: event.rfaPublicId,
rfaRevisionPublicId: event.rfaRevisionPublicId,
projectId: event.projectId,
documentTypeId: event.documentTypeId,
documentTypeCode: event.documentTypeCode,
responseCode: event.responseCode,
approvedAt: event.approvedAt,
@@ -1,9 +1,33 @@
// File: src/modules/distribution/services/transmittal-creator.service.ts
// Change Log
// - 2026-05-14: Use schema-aligned Matrix conditions and canonical documentTypeId lookup.
// สร้าง Transmittal records จาก Distribution jobs (T057)
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { DataSource, IsNull, Repository } from 'typeorm';
import { DistributionMatrix } from '../entities/distribution-matrix.entity';
import { DistributionRecipient } from '../entities/distribution-recipient.entity';
import { DeliveryMethod, RecipientType } from '../../common/enums/review.enums';
import { CorrespondenceRevision } from '../../correspondence/entities/correspondence-revision.entity';
import { Correspondence } from '../../correspondence/entities/correspondence.entity';
import { CorrespondenceType } from '../../correspondence/entities/correspondence-type.entity';
import { CorrespondenceStatus } from '../../correspondence/entities/correspondence-status.entity';
import { CorrespondenceRecipient } from '../../correspondence/entities/correspondence-recipient.entity';
import { Transmittal } from '../../transmittal/entities/transmittal.entity';
import { TransmittalItem } from '../../transmittal/entities/transmittal-item.entity';
import { DocumentNumberingService } from '../../document-numbering/services/document-numbering.service';
import { Organization } from '../../organization/entities/organization.entity';
import { User } from '../../user/entities/user.entity';
export interface DistributionNotificationTarget {
userId: number;
deliveryMethod: DeliveryMethod;
}
export interface DistributionCreationResult {
transmittalPublicIds: string[];
notificationTargets: DistributionNotificationTarget[];
}
/**
* TransmittalCreatorService — ใช้ Strangler Pattern ไม่แก้ไข TransmittalService เดิม
@@ -15,7 +39,9 @@ export class TransmittalCreatorService {
constructor(
@InjectRepository(DistributionMatrix)
private readonly matrixRepo: Repository<DistributionMatrix>
private readonly matrixRepo: Repository<DistributionMatrix>,
private readonly dataSource: DataSource,
private readonly numberingService: DocumentNumberingService
) {}
/**
@@ -26,44 +52,271 @@ export class TransmittalCreatorService {
rfaPublicId: string;
rfaRevisionPublicId: string;
projectId: number;
documentTypeCode: string;
documentTypeId?: number;
documentTypeCode?: string;
responseCode: string;
}): Promise<{ transmittalPublicIds: string[] }> {
}): Promise<DistributionCreationResult> {
if (!payload.documentTypeId) {
this.logger.warn(
`Distribution skipped for RFA ${payload.rfaPublicId}: documentTypeId missing`
);
return { transmittalPublicIds: [], notificationTargets: [] };
}
const matrix = await this.matrixRepo.findOne({
where: {
projectId: payload.projectId,
documentTypeCode: payload.documentTypeCode,
isActive: true,
},
where: [
{
projectId: payload.projectId,
documentTypeId: payload.documentTypeId,
isActive: true,
},
{
projectId: IsNull(),
documentTypeId: payload.documentTypeId,
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}`
`No distribution matrix found for project ${payload.projectId}, docType ${payload.documentTypeId}`
);
return { transmittalPublicIds: [] };
return { transmittalPublicIds: [], notificationTargets: [] };
}
// ตรวจสอบ response code filter
if (
matrix.responseCodeFilter &&
matrix.responseCodeFilter.length > 0 &&
!matrix.responseCodeFilter.includes(payload.responseCode)
matrix.conditions?.codes &&
matrix.conditions.codes.length > 0 &&
!matrix.conditions.codes.includes(payload.responseCode)
) {
this.logger.log(
`Response code ${payload.responseCode} not in filter — skipping distribution`
);
return { transmittalPublicIds: [] };
return { transmittalPublicIds: [], notificationTargets: [] };
}
if (matrix.conditions?.excludeCodes?.includes(payload.responseCode)) {
this.logger.log(
`Response code ${payload.responseCode} is excluded — skipping distribution`
);
return { transmittalPublicIds: [], notificationTargets: [] };
}
const sourceRevision = await this.dataSource.manager.findOne(
CorrespondenceRevision,
{
where: { publicId: payload.rfaRevisionPublicId },
relations: ['correspondence'],
}
);
if (!sourceRevision?.correspondence) {
this.logger.warn(
`Distribution skipped for RFA ${payload.rfaPublicId}: source revision not found`
);
return { transmittalPublicIds: [], notificationTargets: [] };
}
const recipientOrganizationIds = await this.resolveRecipientOrganizations(
matrix.recipients
);
const notificationTargets = await this.resolveNotificationTargets(
matrix.recipients
);
if (recipientOrganizationIds.length === 0) {
this.logger.warn(
`Distribution skipped for RFA ${payload.rfaPublicId}: no organization recipients resolved`
);
return { transmittalPublicIds: [], notificationTargets };
}
this.logger.log(
`Creating Transmittal for RFA ${payload.rfaPublicId}${matrix.recipients.length} recipients`
`Creating Transmittal for RFA ${payload.rfaPublicId}${recipientOrganizationIds.length} recipient organizations`
);
// TODO: เรียก TransmittalService.create() เมื่อ integrate ใน Sprint ถัดไป
// return transmittalService.createDraft({ rfaPublicId, recipients });
const transmittalPublicIds: string[] = [];
for (const recipientOrganizationId of recipientOrganizationIds) {
const existingPublicId = await this.findExistingTransmittalPublicId(
sourceRevision.correspondence.id,
recipientOrganizationId
);
if (existingPublicId) {
transmittalPublicIds.push(existingPublicId);
continue;
}
const createdPublicId = await this.createDraftTransmittal({
sourceCorrespondence: sourceRevision.correspondence,
recipientOrganizationId,
payload,
});
if (createdPublicId) transmittalPublicIds.push(createdPublicId);
}
return { transmittalPublicIds, notificationTargets };
}
return { transmittalPublicIds: [] };
private async resolveNotificationTargets(
recipients: DistributionRecipient[]
): Promise<DistributionNotificationTarget[]> {
const targets = new Map<number, DistributionNotificationTarget>();
for (const recipient of recipients) {
if (recipient.recipientType !== RecipientType.USER) continue;
const user = await this.dataSource.manager.findOne(User, {
where: { publicId: recipient.recipientPublicId },
});
if (!user) continue;
targets.set(user.user_id, {
userId: user.user_id,
deliveryMethod: recipient.deliveryMethod,
});
}
return Array.from(targets.values());
}
private async resolveRecipientOrganizations(
recipients: DistributionRecipient[]
): Promise<number[]> {
const organizationIds = new Set<number>();
for (const recipient of recipients) {
const organizationId =
await this.resolveRecipientOrganizationId(recipient);
if (organizationId) organizationIds.add(organizationId);
}
return Array.from(organizationIds);
}
private async resolveRecipientOrganizationId(
recipient: DistributionRecipient
): Promise<number | undefined> {
if (recipient.deliveryMethod === DeliveryMethod.IN_APP) return undefined;
if (recipient.recipientType === RecipientType.ORGANIZATION) {
const organization = await this.dataSource.manager.findOne(Organization, {
where: { publicId: recipient.recipientPublicId },
});
return organization?.id;
}
if (recipient.recipientType === RecipientType.USER) {
const user = await this.dataSource.manager.findOne(User, {
where: { publicId: recipient.recipientPublicId },
});
return user?.primaryOrganizationId;
}
this.logger.warn(
`Recipient type ${recipient.recipientType} requires expansion and was skipped for Transmittal creation`
);
return undefined;
}
private async findExistingTransmittalPublicId(
sourceCorrespondenceId: number,
recipientOrganizationId: number
): Promise<string | undefined> {
const rows = await this.dataSource.query<Array<{ publicId: string }>>(
`
SELECT c.uuid AS publicId
FROM transmittals t
INNER JOIN correspondences c ON c.id = t.correspondence_id
INNER JOIN transmittal_items ti ON ti.transmittal_id = t.correspondence_id
INNER JOIN correspondence_recipients cr ON cr.correspondence_id = t.correspondence_id
WHERE ti.item_correspondence_id = ?
AND cr.recipient_organization_id = ?
LIMIT 1
`,
[sourceCorrespondenceId, recipientOrganizationId]
);
return rows[0]?.publicId;
}
private async createDraftTransmittal(context: {
sourceCorrespondence: Correspondence;
recipientOrganizationId: number;
payload: {
rfaPublicId: string;
projectId: number;
responseCode: string;
};
}): Promise<string | undefined> {
const type = await this.dataSource.manager.findOne(CorrespondenceType, {
where: { typeCode: 'TRN' },
});
const status = await this.dataSource.manager.findOne(CorrespondenceStatus, {
where: { statusCode: 'DRAFT' },
});
if (!type || !status || !context.sourceCorrespondence.originatorId) {
this.logger.warn(
`Distribution skipped for RFA ${context.payload.rfaPublicId}: missing Transmittal master data or originator`
);
return undefined;
}
const docNumber = await this.numberingService.generateNextNumber({
projectId: context.payload.projectId,
originatorOrganizationId: context.sourceCorrespondence.originatorId,
recipientOrganizationId: context.recipientOrganizationId,
typeId: type.id,
year: new Date().getFullYear(),
customTokens: {
TYPE_CODE: type.typeCode,
RESPONSE_CODE: context.payload.responseCode,
},
});
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const correspondence = queryRunner.manager.create(Correspondence, {
correspondenceNumber: docNumber.number,
correspondenceTypeId: type.id,
projectId: context.payload.projectId,
originatorId: context.sourceCorrespondence.originatorId,
isInternal: false,
});
const savedCorrespondence =
await queryRunner.manager.save(correspondence);
const revision = queryRunner.manager.create(CorrespondenceRevision, {
correspondenceId: savedCorrespondence.id,
revisionNumber: 0,
revisionLabel: '0',
isCurrent: true,
statusId: status.id,
subject: `Distribution for ${context.sourceCorrespondence.correspondenceNumber}`,
details: {
sourceRfaPublicId: context.payload.rfaPublicId,
},
});
await queryRunner.manager.save(revision);
const recipient = queryRunner.manager.create(CorrespondenceRecipient, {
correspondenceId: savedCorrespondence.id,
recipientOrganizationId: context.recipientOrganizationId,
recipientType: 'TO',
});
await queryRunner.manager.save(recipient);
const transmittal = queryRunner.manager.create(Transmittal, {
correspondenceId: savedCorrespondence.id,
purpose: 'FOR_INFORMATION',
remarks: `Auto-distributed from RFA ${context.payload.rfaPublicId}`,
});
await queryRunner.manager.save(transmittal);
const item = queryRunner.manager.create(TransmittalItem, {
transmittalId: savedCorrespondence.id,
itemCorrespondenceId: context.sourceCorrespondence.id,
quantity: 1,
remarks: `RFA response code ${context.payload.responseCode}`,
});
await queryRunner.manager.save(item);
await queryRunner.commitTransaction();
return savedCorrespondence.publicId;
} catch (error: unknown) {
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release();
}
}
}
@@ -14,6 +14,10 @@ export class CreateReminderRuleDto {
@IsInt()
projectId?: number;
@IsString()
@MaxLength(100)
name!: string;
@IsOptional()
@IsString()
@MaxLength(20)
@@ -33,4 +37,8 @@ export class CreateReminderRuleDto {
@IsArray()
@IsString({ each: true })
notifyRoles?: string[];
@IsOptional()
@IsString()
messageTemplate?: string;
}
@@ -0,0 +1,50 @@
// File: src/modules/reminder/entities/reminder-history.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 { ReviewTask } from '../../review-team/entities/review-task.entity';
import { User } from '../../user/entities/user.entity';
import { ReminderType } from '../../common/enums/review.enums';
@Entity('reminder_histories')
export class ReminderHistory extends UuidBaseEntity {
@PrimaryGeneratedColumn()
@Exclude()
id!: number;
@Column({ name: 'task_id' })
@Exclude()
taskId!: number;
@Column({ name: 'user_id' })
@Exclude()
userId!: number;
@Column({
type: 'enum',
enum: ReminderType,
})
reminderType!: ReminderType;
@Column({ name: 'escalation_level', type: 'tinyint', default: 0 })
escalationLevel!: number;
@CreateDateColumn({ name: 'sent_at' })
sentAt!: Date;
// Relations
@ManyToOne(() => ReviewTask)
@JoinColumn({ name: 'task_id' })
task?: ReviewTask;
@ManyToOne(() => User)
@JoinColumn({ name: 'user_id' })
user?: User;
}
@@ -20,6 +20,9 @@ export class ReminderRule extends UuidBaseEntity {
@Exclude()
projectId?: number; // NULL = global rule
@Column({ length: 100 })
name!: string;
@Column({ name: 'document_type_code', length: 20, nullable: true })
documentTypeCode?: string; // 'SDW', 'DDW' — NULL = all types
@@ -38,6 +41,9 @@ export class ReminderRule extends UuidBaseEntity {
@Column({ name: 'notify_roles', type: 'simple-array', nullable: true })
notifyRoles?: string[]; // เช่น ['TASK_ASSIGNEE', 'TEAM_LEAD', 'PROJECT_MANAGER']
@Column({ name: 'message_template', type: 'text', nullable: true })
messageTemplate?: string;
@Column({ name: 'is_active', type: 'tinyint', default: 1 })
isActive!: boolean;
@@ -3,11 +3,14 @@
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Logger } from '@nestjs/common';
import { Job } from 'bullmq';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
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';
import { ReviewTask } from '../../review-team/entities/review-task.entity';
@Processor(QUEUE_REMINDERS)
export class ReminderProcessor extends WorkerHost {
@@ -15,7 +18,9 @@ export class ReminderProcessor extends WorkerHost {
constructor(
private readonly escalationService: EscalationService,
private readonly notificationService: NotificationService
private readonly notificationService: NotificationService,
@InjectRepository(ReviewTask)
private readonly taskRepo: Repository<ReviewTask>
) {
super();
}
@@ -27,17 +32,28 @@ export class ReminderProcessor extends WorkerHost {
`Processing reminder job: ${reminderType} for task ${taskPublicId}`
);
// ดึง internal ID ของ task
const task = await this.taskRepo.findOne({
where: { publicId: taskPublicId },
select: ['id', 'assignedToUserId'],
});
if (!task) {
this.logger.warn(`Task ${taskPublicId} not found — skipping reminder`);
return;
}
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.',
message: 'Your review task is due soon. Please complete your review.',
type: 'SYSTEM',
entityType: 'review_task',
entityId: taskPublicId as unknown as number,
entityId: task.id,
});
await this.escalationService.recordHistory(task, reminderType, 0);
break;
case ReminderType.ON_DUE:
@@ -48,8 +64,9 @@ export class ReminderProcessor extends WorkerHost {
'Your review task is due today. Please complete it as soon as possible.',
type: 'SYSTEM',
entityType: 'review_task',
entityId: taskPublicId as unknown as number,
entityId: task.id,
});
await this.escalationService.recordHistory(task, reminderType, 0);
break;
case ReminderType.OVERDUE:
@@ -60,8 +77,9 @@ export class ReminderProcessor extends WorkerHost {
'Your review task is overdue. Escalation will occur if not completed.',
type: 'SYSTEM',
entityType: 'review_task',
entityId: taskPublicId as unknown as number,
entityId: task.id,
});
await this.escalationService.recordHistory(task, reminderType, 0);
break;
case ReminderType.ESCALATION_L1:
@@ -25,6 +25,11 @@ export class ReminderController {
return this.reminderService.findAllByProjectPublicId(projectPublicId);
}
@Get('history/:taskPublicId')
getHistory(@Param('taskPublicId') taskPublicId: string) {
return this.reminderService.findHistoryByTaskPublicId(taskPublicId);
}
@Get(':publicId')
findOne(@Param('publicId') publicId: string) {
return this.reminderService.findOne(publicId);
@@ -3,6 +3,7 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BullModule } from '@nestjs/bullmq';
import { ReminderRule } from './entities/reminder-rule.entity';
import { ReminderHistory } from './entities/reminder-history.entity';
import { ReviewTask } from '../review-team/entities/review-task.entity';
import { ReminderService } from './reminder.service';
import { ReminderController } from './reminder.controller';
@@ -15,7 +16,12 @@ import { Project } from '../project/entities/project.entity';
@Module({
imports: [
TypeOrmModule.forFeature([ReminderRule, ReviewTask, Project]),
TypeOrmModule.forFeature([
ReminderRule,
ReminderHistory,
ReviewTask,
Project,
]),
BullModule.registerQueue({ name: QUEUE_REMINDERS }),
NotificationModule,
],
@@ -10,8 +10,10 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { validate as uuidValidate } from 'uuid';
import { ReminderRule } from './entities/reminder-rule.entity';
import { ReminderHistory } from './entities/reminder-history.entity';
import { CreateReminderRuleDto } from './dto/create-reminder-rule.dto';
import { Project } from '../project/entities/project.entity';
import { ReviewTask } from '../review-team/entities/review-task.entity';
export { CreateReminderRuleDto };
@@ -22,8 +24,12 @@ export class ReminderService {
constructor(
@InjectRepository(ReminderRule)
private readonly ruleRepo: Repository<ReminderRule>,
@InjectRepository(ReminderHistory)
private readonly historyRepo: Repository<ReminderHistory>,
@InjectRepository(Project)
private readonly projectRepo: Repository<Project>
private readonly projectRepo: Repository<Project>,
@InjectRepository(ReviewTask)
private readonly taskRepo: Repository<ReviewTask>
) {}
async findAll(projectId?: number): Promise<ReminderRule[]> {
@@ -58,6 +64,21 @@ export class ReminderService {
return rule;
}
async findHistoryByTaskPublicId(
taskPublicId: string
): Promise<ReminderHistory[]> {
const task = await this.taskRepo.findOne({
where: { publicId: taskPublicId },
});
if (!task) throw new NotFoundException('Task', taskPublicId);
return this.historyRepo.find({
where: { taskId: task.id },
relations: ['user'],
order: { sentAt: 'DESC' },
});
}
async create(dto: CreateReminderRuleDto): Promise<ReminderRule> {
const rule = this.ruleRepo.create(dto as Partial<ReminderRule>);
return this.ruleRepo.save(rule);
@@ -4,9 +4,13 @@ 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 {
ReviewTaskStatus,
ReminderType,
} from '../../common/enums/review.enums';
import { NotificationService } from '../../notification/notification.service';
import { ReminderRule } from '../entities/reminder-rule.entity';
import { ReminderHistory } from '../entities/reminder-history.entity';
@Injectable()
export class EscalationService {
@@ -17,12 +21,40 @@ export class EscalationService {
private readonly reviewTaskRepo: Repository<ReviewTask>,
@InjectRepository(ReminderRule)
private readonly reminderRuleRepo: Repository<ReminderRule>,
@InjectRepository(ReminderHistory)
private readonly historyRepo: Repository<ReminderHistory>,
private readonly notificationService: NotificationService
) {}
/**
* บันทึกประวัติการส่ง reminder (FR-018)
*/
async recordHistory(
task: ReviewTask,
type: ReminderType,
level: number
): Promise<void> {
const history = this.historyRepo.create({
taskId: task.id,
userId: task.assignedToUserId,
reminderType: type,
escalationLevel: level,
});
await this.historyRepo.save(history);
}
/**
* นับจำนวนครั้งที่ส่ง reminder สำหรับ level นั้นๆ
*/
async getStrikeCount(taskId: number, level: number): Promise<number> {
return this.historyRepo.count({
where: { taskId, escalationLevel: level },
});
}
/**
* Escalation Level 1 (FR-015): Team Lead ได้รับแจ้งเตือน
* เรียกเมื่อ task เกิน due date 1 วัน
* เรียกเมื่อ task เกิน due date
*/
async escalateLevel1(taskPublicId: string): Promise<void> {
const task = await this.reviewTaskRepo.findOne({
@@ -32,32 +64,35 @@ export class EscalationService {
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;
const strikes = await this.getStrikeCount(task.id, 1);
if (strikes >= 3) {
this.logger.log(
`Task ${taskPublicId} L1 strikes reached 3 — moving to L2`
);
await this.escalateLevel2(taskPublicId);
return;
}
this.logger.log(
`Escalation L1: task ${taskPublicId} is ${daysOverdue} days overdue`
`Escalation L1 (Strike ${strikes + 1}): task ${taskPublicId}`
);
// แจ้ง 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.`,
title: `⚠ Review Task Overdue (L1 Strike ${strikes + 1})`,
message: `Your review task is overdue. Please complete it immediately.`,
type: 'SYSTEM',
entityType: 'review_task',
entityId: task.id,
});
await this.recordHistory(task, ReminderType.ESCALATION_L1, 1);
}
}
/**
* Escalation Level 2 (FR-016): Project Manager ได้รับแจ้งเตือน
* เรียกเมื่อ task เกิน due date 3 วัน
* เรียกเมื่อ L1 ครบ 3 ครั้ง หรือตามเงื่อนไข SLA
*/
async escalateLevel2(taskPublicId: string): Promise<void> {
const task = await this.reviewTaskRepo.findOne({
@@ -67,20 +102,25 @@ export class EscalationService {
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;
const strikes = await this.getStrikeCount(task.id, 2);
this.logger.warn(
`Escalation L2: task ${taskPublicId} is ${daysOverdue} days overdue — escalating to PM`
`Escalation L2 (Strike ${strikes + 1}): task ${taskPublicId} — escalating to PM`
);
// TODO: ดึง PM user ID จาก project membership — ใช้ placeholder สำหรับตอนนี้
this.logger.log(
`L2 escalation notification queued for task ${taskPublicId}`
);
// TODO: ดึง PM user ID จาก project membership
// สำหรับตอนนี้ แจ้งผู้รับผิดชอบเดิมแต่หัวเรื่องแรงขึ้น
if (task.assignedToUserId) {
await this.notificationService.send({
userId: task.assignedToUserId,
title: `🛑 CRITICAL: Review Task Overdue (L2 Strike ${strikes + 1})`,
message: `Your review task is critically overdue. Project Management has been notified.`,
type: 'SYSTEM',
entityType: 'review_task',
entityId: task.id,
});
await this.recordHistory(task, ReminderType.ESCALATION_L2, 2);
}
}
/**
@@ -100,14 +140,23 @@ export class EscalationService {
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;
// ดึง history ล่าสุดเพื่อดูว่าควร escalate level ไหน
const lastHistory = await this.historyRepo.findOne({
where: { taskId: task.id },
order: { sentAt: 'DESC' },
});
if (daysOverdue >= 3) {
await this.escalateLevel2(task.publicId);
} else if (daysOverdue >= 1) {
if (!lastHistory || lastHistory.escalationLevel === 0) {
await this.escalateLevel1(task.publicId);
} else if (lastHistory.escalationLevel === 1) {
const strikes = await this.getStrikeCount(task.id, 1);
if (strikes >= 3) {
await this.escalateLevel2(task.publicId);
} else {
await this.escalateLevel1(task.publicId); // FIXED typo
}
} else if (lastHistory.escalationLevel === 2) {
await this.escalateLevel2(task.publicId); // Daily reminder for L2
}
}
}
@@ -6,6 +6,9 @@ import { Queue } from 'bullmq';
import { QUEUE_REMINDERS } from '../../common/constants/queue.constants';
import type { Job } from 'bullmq';
import { ReminderType } from '../../common/enums/review.enums';
import { ReminderRule } from '../entities/reminder-rule.entity';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
export interface ScheduleReminderPayload {
taskPublicId: string;
@@ -13,6 +16,8 @@ export interface ScheduleReminderPayload {
assigneeUserId: number;
dueDate: Date;
reminderType: ReminderType;
projectId?: number;
documentTypeCode?: string;
}
type ReminderJob = Job<ScheduleReminderPayload>;
@@ -23,64 +28,67 @@ export class SchedulerService {
constructor(
@InjectQueue(QUEUE_REMINDERS)
private readonly reminderQueue: Queue
private readonly reminderQueue: Queue,
@InjectRepository(ReminderRule)
private readonly ruleRepo: Repository<ReminderRule>
) {}
/**
* Schedule ชุด reminders ให้ Review Task (FR-013)
* Schedule ชุด reminders ให้ Review Task (FR-013) ตาม ReminderRule
* เรียกหลังจาก TaskCreationService สร้าง tasks เรียบร้อยแล้ว
*/
async scheduleForTask(payload: ScheduleReminderPayload): Promise<void> {
const { taskPublicId, dueDate } = payload;
const { taskPublicId, dueDate, projectId, documentTypeCode } = 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),
// ดึงกฎที่เกี่ยวข้อง (Global + Project specific)
const rules = await this.ruleRepo.find({
where: [
{ projectId, documentTypeCode, isActive: true },
{ projectId: undefined, documentTypeCode, isActive: true },
{ projectId, documentTypeCode: undefined, isActive: true },
{ projectId: undefined, documentTypeCode: undefined, isActive: true },
],
});
// 3 วันหลัง due (Escalation L2)
const threeDaysAfter = dueDate.getTime() + 3 * 86_400_000;
remindersToSchedule.push({
type: ReminderType.ESCALATION_L2,
delayMs: Math.max(threeDaysAfter - now, 0),
});
if (rules.length === 0) {
this.logger.debug(`No reminder rules found for task ${taskPublicId}`);
return;
}
await Promise.all(
remindersToSchedule.map(({ type, delayMs }) =>
const jobs = [];
for (const rule of rules) {
const triggerTime =
dueDate.getTime() - rule.daysBeforeDue * 24 * 60 * 60 * 1000;
const delayMs = triggerTime - now;
// ถ้าเวลาผ่านไปแล้ว ไม่ต้อง schedule (ยกเว้น overdue ที่อาจจะต้องการส่งทันที)
if (delayMs <= 0 && rule.reminderType !== ReminderType.OVERDUE) {
continue;
}
jobs.push(
this.reminderQueue.add(
'send-reminder',
{ ...payload, reminderType: type },
{ delay: delayMs, removeOnComplete: true, removeOnFail: 100 }
{
...payload,
reminderType: rule.reminderType,
},
{
delay: Math.max(delayMs, 0),
removeOnComplete: true,
removeOnFail: 100,
jobId: `${taskPublicId}-${rule.reminderType}-${rule.id}`, // ป้องกัน duplicate
}
)
)
);
);
}
await Promise.all(jobs);
this.logger.log(
`Scheduled ${remindersToSchedule.length} reminders for task ${taskPublicId}`
`Scheduled ${jobs.length} reminders for task ${taskPublicId} based on ${rules.length} rules`
);
}
@@ -0,0 +1,54 @@
// File: src/modules/response-code/dto/create-response-code.dto.ts
// Change Log:
// - 2026-05-13: Add DTO for creating custom response codes.
import {
IsArray,
IsBoolean,
IsEnum,
IsObject,
IsOptional,
IsString,
MaxLength,
MinLength,
} from 'class-validator';
import { ResponseCodeCategory } from '../../common/enums/review.enums';
export class CreateResponseCodeDto {
@IsString()
@MinLength(1)
@MaxLength(10)
code!: string;
@IsOptional()
@IsString()
@MaxLength(10)
subStatus?: string;
@IsEnum(ResponseCodeCategory)
category!: ResponseCodeCategory;
@IsString()
descriptionTh!: string;
@IsString()
descriptionEn!: string;
@IsOptional()
@IsObject()
implications?: {
affectsSchedule?: boolean;
affectsCost?: boolean;
requiresContractReview?: boolean;
requiresEiaAmendment?: boolean;
};
@IsOptional()
@IsArray()
@IsString({ each: true })
notifyRoles?: string[];
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
@@ -0,0 +1,58 @@
// File: src/modules/response-code/dto/update-response-code.dto.ts
// Change Log:
// - 2026-05-13: Add DTO for updating response codes by publicId.
import {
IsArray,
IsBoolean,
IsEnum,
IsObject,
IsOptional,
IsString,
MaxLength,
MinLength,
} from 'class-validator';
import { ResponseCodeCategory } from '../../common/enums/review.enums';
export class UpdateResponseCodeDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(10)
code?: string;
@IsOptional()
@IsString()
@MaxLength(10)
subStatus?: string;
@IsOptional()
@IsEnum(ResponseCodeCategory)
category?: ResponseCodeCategory;
@IsOptional()
@IsString()
descriptionTh?: string;
@IsOptional()
@IsString()
descriptionEn?: string;
@IsOptional()
@IsObject()
implications?: {
affectsSchedule?: boolean;
affectsCost?: boolean;
requiresContractReview?: boolean;
requiresEiaAmendment?: boolean;
};
@IsOptional()
@IsArray()
@IsString({ each: true })
notifyRoles?: string[];
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
@@ -0,0 +1,29 @@
// File: src/modules/response-code/dto/upsert-response-code-rule.dto.ts
// Change Log:
// - 2026-05-13: Add DTO for response code matrix rule upsert endpoints.
import { IsBoolean, IsInt, IsOptional, IsUUID, Min } from 'class-validator';
export class UpsertResponseCodeRuleDto {
@IsInt()
@Min(1)
documentTypeId!: number;
@IsUUID()
responseCodePublicId!: string;
@IsOptional()
@IsUUID()
projectPublicId?: string;
@IsBoolean()
isEnabled!: boolean;
@IsOptional()
@IsBoolean()
requiresComments?: boolean;
@IsOptional()
@IsBoolean()
triggersNotification?: boolean;
}
@@ -1,19 +1,51 @@
// File: src/modules/response-code/response-code.controller.ts
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
// Change Log:
// - 2026-05-13: Resolve project query identifiers through UuidResolverService and stop numeric coercion on public IDs.
// - 2026-05-13: Add basic CRUD endpoints with RBAC enforcement for response code management.
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
ParseIntPipe,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RbacGuard } from '../../common/guards/rbac.guard';
import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe';
import { UuidResolverService } from '../../common/services/uuid-resolver.service';
import { ResponseCodeService } from './response-code.service';
import { ResponseCodeCategory } from '../common/enums/review.enums';
import { CreateResponseCodeDto } from './dto/create-response-code.dto';
import { UpdateResponseCodeDto } from './dto/update-response-code.dto';
import { UpsertResponseCodeRuleDto } from './dto/upsert-response-code-rule.dto';
import { MatrixManagementService } from './services/matrix-management.service';
import { InheritanceService } from './services/inheritance.service';
@ApiTags('Response Codes')
@ApiBearerAuth()
@Controller('response-codes')
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, RbacGuard)
export class ResponseCodeController {
constructor(private readonly responseCodeService: ResponseCodeService) {}
constructor(
private readonly responseCodeService: ResponseCodeService,
private readonly uuidResolver: UuidResolverService,
private readonly matrixManagementService: MatrixManagementService,
private readonly inheritanceService: InheritanceService
) {}
/**
* GET /response-codes
* ดึง Response Codes ทั้งหมด
*/
@Get()
@ApiOperation({ summary: 'Get all active response codes' })
findAll() {
return this.responseCodeService.findAll();
}
@@ -23,6 +55,7 @@ export class ResponseCodeController {
* ดึง Response Codes ตาม Category (FR-006)
*/
@Get('category/:category')
@ApiOperation({ summary: 'Get response codes by category' })
findByCategory(@Param('category') category: ResponseCodeCategory) {
return this.responseCodeService.findByCategory(category);
}
@@ -32,13 +65,18 @@ export class ResponseCodeController {
* ดึง Response Codes ที่ใช้ได้กับ document type + project
*/
@Get('document-type/:documentTypeId')
findByDocumentType(
@Param('documentTypeId') documentTypeId: string,
@ApiOperation({ summary: 'Get response codes by document type and project' })
async findByDocumentType(
@Param('documentTypeId', ParseIntPipe) documentTypeId: number,
@Query('projectId') projectId?: string
) {
const resolvedProjectId = projectId
? await this.uuidResolver.resolveProjectId(projectId)
: undefined;
return this.responseCodeService.findByDocumentType(
Number(documentTypeId),
projectId ? Number(projectId) : undefined
documentTypeId,
resolvedProjectId
);
}
@@ -47,7 +85,79 @@ export class ResponseCodeController {
* ดึง Response Code ตาม publicId (ADR-019)
*/
@Get(':publicId')
findOne(@Param('publicId') publicId: string) {
@ApiOperation({ summary: 'Get response code by publicId' })
findOne(@Param('publicId', ParseUuidPipe) publicId: string) {
return this.responseCodeService.findByPublicId(publicId);
}
@Post()
@RequirePermission('master_data.manage')
@ApiOperation({ summary: 'Create a custom response code' })
create(@Body() dto: CreateResponseCodeDto) {
return this.responseCodeService.create(dto);
}
@Patch(':publicId')
@RequirePermission('master_data.manage')
@ApiOperation({ summary: 'Update response code by publicId' })
update(
@Param('publicId', ParseUuidPipe) publicId: string,
@Body() dto: UpdateResponseCodeDto
) {
return this.responseCodeService.update(publicId, dto);
}
@Delete(':publicId')
@RequirePermission('master_data.manage')
@ApiOperation({ summary: 'Deactivate response code by publicId' })
async remove(@Param('publicId', ParseUuidPipe) publicId: string) {
await this.responseCodeService.deactivate(publicId);
return { success: true };
}
@Get('matrix/:documentTypeId')
@ApiOperation({ summary: 'Resolve response code matrix by document type' })
async getMatrix(
@Param('documentTypeId', ParseIntPipe) documentTypeId: number,
@Query('projectId') projectId?: string
) {
const resolvedProjectId = projectId
? await this.uuidResolver.resolveProjectId(projectId)
: undefined;
return this.inheritanceService.resolveMatrix(
documentTypeId,
resolvedProjectId
);
}
@Post('matrix/rules')
@RequirePermission('master_data.manage')
@ApiOperation({ summary: 'Create or update a response code matrix rule' })
async upsertRule(@Body() dto: UpsertResponseCodeRuleDto) {
const resolvedProjectId = dto.projectPublicId
? await this.uuidResolver.resolveProjectId(dto.projectPublicId)
: undefined;
return this.matrixManagementService.upsertRule({
documentTypeId: dto.documentTypeId,
responseCodePublicId: dto.responseCodePublicId,
projectId: resolvedProjectId,
isEnabled: dto.isEnabled,
requiresComments: dto.requiresComments,
triggersNotification: dto.triggersNotification,
});
}
@Delete('matrix/rules/:rulePublicId')
@RequirePermission('master_data.manage')
@ApiOperation({
summary: 'Delete a project-specific response code matrix override',
})
async deleteRuleOverride(
@Param('rulePublicId', ParseUuidPipe) rulePublicId: string
) {
await this.matrixManagementService.deleteProjectOverride(rulePublicId);
return { success: true };
}
}
@@ -1,10 +1,12 @@
// File: src/modules/response-code/response-code.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuditLog } from '../../common/entities/audit-log.entity';
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 { ResponseCodeAuditService } from './services/audit.service';
import { ImplicationsService } from './services/implications.service';
import { NotificationTriggerService } from './services/notification-trigger.service';
import { MatrixManagementService } from './services/matrix-management.service';
@@ -14,11 +16,12 @@ import { NotificationModule } from '../notification/notification.module';
@Module({
imports: [
TypeOrmModule.forFeature([ResponseCode, ResponseCodeRule, User]),
TypeOrmModule.forFeature([ResponseCode, ResponseCodeRule, User, AuditLog]),
NotificationModule,
],
providers: [
ResponseCodeService,
ResponseCodeAuditService,
ImplicationsService,
NotificationTriggerService,
MatrixManagementService,
@@ -27,6 +30,7 @@ import { NotificationModule } from '../notification/notification.module';
controllers: [ResponseCodeController],
exports: [
ResponseCodeService,
ResponseCodeAuditService,
ImplicationsService,
NotificationTriggerService,
MatrixManagementService,
@@ -1,10 +1,20 @@
// File: src/modules/response-code/response-code.service.ts
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
// Change Log:
// - 2026-05-13: Add basic CRUD methods for response codes to support controller mutations.
import {
ConflictException,
Injectable,
Logger,
NotFoundException,
BadRequestException,
} 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';
import { CreateResponseCodeDto } from './dto/create-response-code.dto';
import { UpdateResponseCodeDto } from './dto/update-response-code.dto';
@Injectable()
export class ResponseCodeService {
@@ -85,6 +95,83 @@ export class ResponseCodeService {
return code;
}
/**
* สร้าง Response Code ใหม่สำหรับ Master Approval Matrix
*/
async create(dto: CreateResponseCodeDto): Promise<ResponseCode> {
const existing = await this.responseCodeRepo.findOne({
where: {
code: dto.code,
category: dto.category,
},
});
if (existing) {
throw new ConflictException(
`Response Code already exists for code=${dto.code}, category=${dto.category}`
);
}
const entity = this.responseCodeRepo.create({
code: dto.code,
subStatus: dto.subStatus,
category: dto.category,
descriptionTh: dto.descriptionTh,
descriptionEn: dto.descriptionEn,
implications: dto.implications,
notifyRoles: dto.notifyRoles,
isActive: dto.isActive ?? true,
isSystem: false,
});
return this.responseCodeRepo.save(entity);
}
/**
* อัปเดต Response Code ตาม publicId
*/
async update(
publicId: string,
dto: UpdateResponseCodeDto
): Promise<ResponseCode> {
const entity = await this.findByPublicId(publicId);
if (
(dto.code && dto.code !== entity.code) ||
(dto.category && dto.category !== entity.category)
) {
const existing = await this.responseCodeRepo.findOne({
where: {
code: dto.code ?? entity.code,
category: dto.category ?? entity.category,
},
});
if (existing && existing.publicId !== entity.publicId) {
throw new ConflictException(
`Response Code already exists for code=${dto.code ?? entity.code}, category=${dto.category ?? entity.category}`
);
}
}
Object.assign(entity, dto);
return this.responseCodeRepo.save(entity);
}
/**
* ปิดการใช้งาน Response Code โดยไม่ลบข้อมูล
*/
async deactivate(publicId: string): Promise<void> {
const entity = await this.findByPublicId(publicId);
if (entity.isSystem) {
throw new BadRequestException('Cannot deactivate a system response code');
}
entity.isActive = false;
await this.responseCodeRepo.save(entity);
}
/**
* ตรวจสอบว่า Response Code triggers notification หรือไม่ (FR-007)
* Code 1C, 1D, 3 → trigger notification
@@ -0,0 +1,49 @@
// File: src/modules/response-code/services/audit.service.ts
// Change Log:
// - 2026-05-13: Add response code audit service for review task response code changes.
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AuditLog } from '../../../common/entities/audit-log.entity';
@Injectable()
export class ResponseCodeAuditService {
private readonly logger = new Logger(ResponseCodeAuditService.name);
constructor(
@InjectRepository(AuditLog)
private readonly auditLogRepo: Repository<AuditLog>
) {}
/**
* บันทึก audit trail เมื่อมีการเลือกหรือเปลี่ยน Response Code บน Review Task
*/
async logReviewTaskResponseCodeChange(input: {
reviewTaskPublicId: string;
responseCodePublicId: string;
previousResponseCodeId?: number;
currentResponseCodeId: number;
comments?: string;
userId?: number;
}): Promise<void> {
const auditLog = this.auditLogRepo.create({
userId: input.userId ?? null,
action: 'response_code.change',
severity: 'INFO',
entityType: 'review_task',
entityId: input.reviewTaskPublicId,
detailsJson: {
previousResponseCodeId: input.previousResponseCodeId ?? null,
currentResponseCodeId: input.currentResponseCodeId,
responseCodePublicId: input.responseCodePublicId,
comments: input.comments ?? null,
},
});
await this.auditLogRepo.save(auditLog);
this.logger.debug(
`Recorded response code audit for review task ${input.reviewTaskPublicId}`
);
}
}
@@ -1,4 +1,6 @@
// File: src/modules/review-team/dto/shared/review-team.dto.ts
// Change Log:
// - 2026-05-13: Align AddTeamMemberDto discipline identifier with the INT-based disciplines schema.
// Shared DTOs สำหรับ Review Team และ Review Task APIs
import {
@@ -69,8 +71,9 @@ export class AddTeamMemberDto {
@IsUUID()
userPublicId!: string; // ADR-019
@IsUUID()
disciplinePublicId!: string; // ADR-019
@IsInt()
@IsPositive()
disciplineId!: number; // disciplines table is internal INT per current schema
@IsEnum(ReviewTeamMemberRole)
role!: ReviewTeamMemberRole;
@@ -0,0 +1,110 @@
// File: src/modules/review-team/review-task.controller.ts
import {
Controller,
Get,
Post,
Patch,
Body,
Param,
Query,
UseGuards,
ParseUUIDPipe,
} from '@nestjs/common';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { ReviewTaskService } from './review-task.service';
import { ConsensusService } from './services/consensus.service';
import { VetoOverrideService } from './services/veto-override.service';
import type { VetoOverrideDto } from './services/veto-override.service';
import {
CompleteReviewTaskDto,
SearchReviewTaskDto,
} from './dto/shared/review-team.dto';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { User } from '../user/entities/user.entity';
@Controller('review-tasks')
@UseGuards(JwtAuthGuard)
export class ReviewTaskController {
constructor(
private readonly reviewTaskService: ReviewTaskService,
private readonly consensusService: ConsensusService,
private readonly vetoOverrideService: VetoOverrideService
) {}
@Get()
findAll(@Query() dto: SearchReviewTaskDto) {
return this.reviewTaskService.findAll(dto);
}
@Get(':publicId')
findOne(@Param('publicId', ParseUUIDPipe) publicId: string) {
return this.reviewTaskService.findByPublicId(publicId);
}
@Patch(':publicId/start')
startReview(@Param('publicId', ParseUUIDPipe) publicId: string) {
return this.reviewTaskService.startReview(publicId);
}
@Patch(':publicId/complete')
async completeReview(
@Param('publicId', ParseUUIDPipe) publicId: string,
@Body() dto: CompleteReviewTaskDto,
@CurrentUser() _user: User
) {
const task = await this.reviewTaskService.completeReview(publicId, dto);
// Evaluate consensus after completion (FR-010)
try {
const fullTask = (await this.reviewTaskService.findFullTaskContext(
publicId
)) as unknown as Record<string, unknown>;
const rfaRevision = fullTask.rfaRevision as
| Record<string, unknown>
| undefined;
const corrRevision = rfaRevision?.correspondenceRevision as
| Record<string, unknown>
| undefined;
const correspondence = corrRevision?.correspondence as
| Record<string, unknown>
| undefined;
if (rfaRevision && correspondence) {
await this.consensusService.evaluateAfterTaskComplete(
fullTask.rfaRevisionId,
{
rfaPublicId: correspondence.publicId as string,
rfaRevisionPublicId: corrRevision.publicId as string,
projectId: correspondence.projectId as number,
documentTypeId: (
correspondence.type as Record<string, unknown> | undefined
)?.id as number | undefined,
documentTypeCode:
((correspondence.type as Record<string, unknown> | undefined)
?.typeCode as string | undefined) ?? 'RFA',
}
);
}
} catch (_error: unknown) {
// Log error but don't fail the task completion response
// (error as any).logger?.error(`Consensus evaluation failed: ${(error as Error).message}`);
}
return task;
}
@Post('veto-override')
async overrideVeto(@Body() dto: VetoOverrideDto, @CurrentUser() user: User) {
return this.vetoOverrideService.executeOverride({
...dto,
overriddenByUserId: user.user_id,
});
}
}
@@ -1,4 +1,6 @@
// File: src/modules/review-team/review-task.service.ts
// Change Log:
// - 2026-05-13: Record audit trail when a review task response code is completed or changed.
import {
Injectable,
Logger,
@@ -10,6 +12,7 @@ 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 { ResponseCodeAuditService } from '../response-code/services/audit.service';
import {
CompleteReviewTaskDto,
SearchReviewTaskDto,
@@ -25,7 +28,8 @@ export class ReviewTaskService {
@InjectRepository(ReviewTask)
private readonly reviewTaskRepo: Repository<ReviewTask>,
@InjectRepository(ResponseCode)
private readonly responseCodeRepo: Repository<ResponseCode>
private readonly responseCodeRepo: Repository<ResponseCode>,
private readonly responseCodeAuditService: ResponseCodeAuditService
) {}
/**
@@ -91,6 +95,48 @@ export class ReviewTaskService {
return task;
}
/**
* ดึง Review Task พร้อม context ทั้งหมด (RFA, Project, Type)
*/
async findFullTaskContext(publicId: string): Promise<ReviewTask> {
const task = await this.reviewTaskRepo
.createQueryBuilder('task')
.leftJoinAndSelect('task.responseCode', 'responseCode')
.leftJoinAndSelect('task.team', 'team')
.innerJoinAndMapOne(
'task.rfaRevision',
'rfa_revisions',
'rfaRev',
'rfaRev.id = task.rfa_revision_id'
)
.innerJoinAndMapOne(
'rfaRev.correspondenceRevision',
'correspondence_revisions',
'corrRev',
'corrRev.id = rfaRev.id'
)
.innerJoinAndMapOne(
'corrRev.correspondence',
'correspondences',
'corr',
'corr.id = corrRev.correspondence_id'
)
.leftJoinAndMapOne(
'corr.type',
'correspondence_types',
'corrType',
'corrType.id = corr.correspondence_type_id'
)
.where('task.uuid = :publicId', { publicId })
.getOne();
if (!task) {
throw new NotFoundException(`Review Task not found: ${publicId}`);
}
return task;
}
/**
* ดึง Tasks รวมทั้งหมดของ RFA Revision พร้อม Aggregate Status (FR-004)
*/
@@ -143,6 +189,7 @@ export class ReviewTaskService {
dto: CompleteReviewTaskDto
): Promise<ReviewTask> {
const task = await this.findByPublicId(publicId);
const previousResponseCodeId = task.responseCodeId;
if (
task.status === ReviewTaskStatus.COMPLETED ||
@@ -180,7 +227,15 @@ export class ReviewTaskService {
try {
// TypeORM จะ throw OptimisticLockVersionMismatchError ถ้า version ไม่ตรง (ADR-002)
return await this.reviewTaskRepo.save(task);
const savedTask = await this.reviewTaskRepo.save(task);
await this.responseCodeAuditService.logReviewTaskResponseCodeChange({
reviewTaskPublicId: savedTask.publicId,
responseCodePublicId: dto.responseCodePublicId,
previousResponseCodeId,
currentResponseCodeId: responseCode.id,
comments: dto.comments,
});
return savedTask;
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
if (
@@ -22,12 +22,15 @@ import { VetoOverrideService } from './services/veto-override.service';
// Controllers
import { ReviewTeamController } from './review-team.controller';
import { ReviewTaskController } from './review-task.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';
import { DelegationModule } from '../delegation/delegation.module';
import { ReminderModule } from '../reminder/reminder.module';
// Queue constants
import {
@@ -52,6 +55,8 @@ import {
NotificationModule,
UserModule,
DistributionModule,
DelegationModule,
ReminderModule,
],
providers: [
ReviewTeamService,
@@ -61,7 +66,7 @@ import {
ConsensusService,
VetoOverrideService,
],
controllers: [ReviewTeamController],
controllers: [ReviewTeamController, ReviewTaskController],
exports: [
ReviewTeamService,
ReviewTaskService,
@@ -1,4 +1,6 @@
// File: src/modules/review-team/review-team.service.ts
// Change Log:
// - 2026-05-13: Resolve project public IDs with UuidResolverService and align discipline lookup with INT discipline IDs.
import {
Injectable,
Logger,
@@ -11,6 +13,7 @@ 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 { UuidResolverService } from '../../common/services/uuid-resolver.service';
import {
CreateReviewTeamDto,
UpdateReviewTeamDto,
@@ -30,7 +33,8 @@ export class ReviewTeamService {
@InjectRepository(User)
private readonly userRepo: Repository<User>,
@InjectRepository(Discipline)
private readonly disciplineRepo: Repository<Discipline>
private readonly disciplineRepo: Repository<Discipline>,
private readonly uuidResolver: UuidResolverService
) {}
/**
@@ -97,21 +101,14 @@ export class ReviewTeamService {
* สร้าง Review Team ใหม่
*/
async create(dto: CreateReviewTeamDto): Promise<ReviewTeam> {
// ตรวจสอบว่า project มีอยู่จริง (via publicId)
const project = await this.teamRepo.manager
.getRepository('projects')
.findOne({
where: { uuid: dto.projectPublicId } as Record<string, unknown>,
});
if (!project) {
throw new NotFoundException(`Project not found: ${dto.projectPublicId}`);
}
const projectId = await this.uuidResolver.resolveProjectId(
dto.projectPublicId
);
const team = this.teamRepo.create({
name: dto.name,
description: dto.description,
projectId: (project as { id: number }).id,
projectId,
defaultForRfaTypes: dto.defaultForRfaTypes,
isActive: true,
});
@@ -155,12 +152,10 @@ export class ReviewTeamService {
// ตรวจสอบ Discipline
const discipline = await this.disciplineRepo.findOne({
where: { id: Number(dto.disciplinePublicId) },
where: { id: dto.disciplineId },
});
if (!discipline)
throw new NotFoundException(
`Discipline not found: ${dto.disciplinePublicId}`
);
throw new NotFoundException(`Discipline not found: ${dto.disciplineId}`);
// ตรวจสอบซ้ำ
const existing = await this.memberRepo.findOne({
@@ -173,7 +168,7 @@ export class ReviewTeamService {
if (existing) {
throw new BadRequestException(
`User ${dto.userPublicId} is already a member of this team for discipline ${dto.disciplinePublicId}`
`User ${dto.userPublicId} is already a member of this team for discipline ${dto.disciplineId}`
);
}
@@ -38,6 +38,7 @@ export class ConsensusService {
rfaPublicId: string;
rfaRevisionPublicId: string;
projectId: number;
documentTypeId?: number;
documentTypeCode: string;
}
): Promise<ConsensusResult> {
@@ -11,7 +11,11 @@ import { ReviewTask } from '../entities/review-task.entity';
import {
ReviewTaskStatus,
ReviewTeamMemberRole,
DelegationScope,
ReminderType,
} from '../../common/enums/review.enums';
import { DelegationService } from '../../delegation/delegation.service';
import { SchedulerService } from '../../reminder/services/scheduler.service';
@Injectable()
export class TaskCreationService {
@@ -23,7 +27,9 @@ export class TaskCreationService {
@InjectRepository(ReviewTeamMember)
private readonly memberRepo: Repository<ReviewTeamMember>,
@InjectRepository(ReviewTask)
private readonly reviewTaskRepo: Repository<ReviewTask>
private readonly reviewTaskRepo: Repository<ReviewTask>,
private readonly delegationService: DelegationService,
private readonly schedulerService: SchedulerService
) {}
/**
@@ -34,12 +40,16 @@ export class TaskCreationService {
* @param reviewTeamPublicId - publicId ของ Review Team (ADR-019)
* @param dueDate - กำหนดเวลาตรวจสอบ
* @param manager - EntityManager จาก QueryRunner (ใช้ Transaction เดิม)
* @param projectId - (Optional) ID ของโครงการ สำหรับ reminder rules
* @param documentTypeCode - (Optional) ประเภทเอกสาร สำหรับ reminder rules
*/
async createParallelTasks(
rfaRevisionId: number,
reviewTeamPublicId: string,
dueDate: Date,
manager: EntityManager
manager: EntityManager,
projectId?: number,
documentTypeCode?: string
): Promise<ReviewTask[]> {
// ดึง ReviewTeam พร้อม members
const team = await this.reviewTeamRepo.findOne({
@@ -77,16 +87,40 @@ export class TaskCreationService {
// สร้าง ReviewTask สำหรับแต่ละ Discipline พร้อมกัน (Parallel)
for (const [disciplineId, leadMember] of disciplineMap) {
const activeDelegate = await this.delegationService.findActiveDelegate(
leadMember.userId,
dueDate,
[DelegationScope.ALL, DelegationScope.RFA_ONLY]
);
const assignedToUserId = activeDelegate?.user_id ?? leadMember.userId;
const delegatedFromUserId = activeDelegate
? leadMember.userId
: undefined;
const task = manager.create(ReviewTask, {
rfaRevisionId,
teamId: team.id,
disciplineId,
assignedToUserId: leadMember.userId,
assignedToUserId,
delegatedFromUserId,
status: ReviewTaskStatus.PENDING,
dueDate,
});
const saved = await manager.save(ReviewTask, task);
tasks.push(saved);
// Schedule Reminders & Escalation (US4)
if (saved.assignedToUserId) {
await this.schedulerService.scheduleForTask({
taskPublicId: saved.publicId,
rfaPublicId: rfaRevisionId.toString(), // ใช้ rfaRevisionId เป็น placeholder
assigneeUserId: saved.assignedToUserId,
dueDate: saved.dueDate ?? dueDate,
reminderType: ReminderType.DUE_SOON, // Start type, scheduler will fetch rules
projectId: projectId ?? team.projectId,
documentTypeCode,
});
}
}
this.logger.log(
@@ -17,6 +17,7 @@ export interface VetoOverrideDto {
rfaPublicId: string;
rfaRevisionPublicId: string;
projectId: number;
documentTypeId?: number;
documentTypeCode: string;
overrideReason: string;
overriddenByUserId: number;
@@ -72,6 +73,7 @@ export class VetoOverrideService {
rfaPublicId: dto.rfaPublicId,
rfaRevisionPublicId: dto.rfaRevisionPublicId,
projectId: dto.projectId,
documentTypeId: dto.documentTypeId,
documentTypeCode: dto.documentTypeCode,
responseCode: '1A',
decision: ConsensusDecision.OVERRIDDEN,
+8 -1
View File
@@ -1,4 +1,6 @@
// File: src/modules/rfa/rfa.controller.ts
// Change Log:
// - 2026-05-13: Wire submit reviewTeamPublicId through to the submit workflow for parallel review task creation.
import {
Body,
Controller,
@@ -76,7 +78,12 @@ export class RfaController {
) {
// ADR-019: resolve UUID → internal INT id via findOneByUuidRaw
const rfa = await this.rfaService.findOneByUuidRaw(uuid);
return this.rfaService.submit(rfa.id, submitDto.templateId, user);
return this.rfaService.submit(
rfa.id,
submitDto.templateId,
user,
submitDto.reviewTeamPublicId
);
}
@Post(':uuid/action')
+2
View File
@@ -34,6 +34,7 @@ import { RfaService } from './rfa.service';
import { DocumentNumberingModule } from '../document-numbering/document-numbering.module';
import { NotificationModule } from '../notification/notification.module';
import { ProjectModule } from '../project/project.module';
import { ReviewTeamModule } from '../review-team/review-team.module';
import { SearchModule } from '../search/search.module';
import { UserModule } from '../user/user.module';
import { WorkflowEngineModule } from '../workflow-engine/workflow-engine.module';
@@ -66,6 +67,7 @@ import { WorkflowEngineModule } from '../workflow-engine/workflow-engine.module'
DocumentNumberingModule,
UserModule,
ProjectModule,
ReviewTeamModule,
SearchModule,
WorkflowEngineModule,
NotificationModule,
+21 -1
View File
@@ -1,4 +1,6 @@
// File: src/modules/rfa/rfa.service.ts
// Change Log:
// - 2026-05-13: Invoke TaskCreationService during submit when a review team is selected.
import { Injectable, Logger } from '@nestjs/common';
import {
@@ -59,6 +61,7 @@ import { SearchService } from '../search/search.service';
import { UserService } from '../user/user.service';
import { WorkflowEngineService } from '../workflow-engine/workflow-engine.service';
import { UuidResolverService } from '../../common/services/uuid-resolver.service';
import { TaskCreationService } from '../review-team/services/task-creation.service';
@Injectable()
export class RfaService {
@@ -109,6 +112,7 @@ export class RfaService {
private userService: UserService,
private workflowEngine: WorkflowEngineService,
private notificationService: NotificationService,
private taskCreationService: TaskCreationService,
private dataSource: DataSource,
private searchService: SearchService,
private uuidResolver: UuidResolverService
@@ -664,7 +668,12 @@ export class RfaService {
return mappedRfa;
}
async submit(rfaId: number, templateId: number, user: User) {
async submit(
rfaId: number,
templateId: number,
user: User,
reviewTeamPublicId?: string
) {
const rfa = await this.findOne(rfaId, true);
const corrRevisions =
(rfa.correspondence?.revisions as CorrRevWithRfa[] | undefined) ?? [];
@@ -747,6 +756,17 @@ export class RfaService {
});
await queryRunner.manager.save(routing);
if (reviewTeamPublicId) {
await this.taskCreationService.createParallelTasks(
currentRfaRev.id,
reviewTeamPublicId,
routing.dueDate ?? new Date(),
queryRunner.manager,
rfa.correspondence.projectId,
rfa.rfaType.typeCode
);
}
// Notify
const recipientUserId = await this.userService.findDocControlIdByOrg(
firstStep.toOrganizationId
@@ -1,3 +1,6 @@
// File: backend/src/modules/user/entities/role.entity.ts
// Change Log:
// - v1.9.0 (2026-05-13): เพิ่ม publicId (uuid) column ตาม delta-11 + ADR-019
import {
Entity,
PrimaryGeneratedColumn,
@@ -6,7 +9,9 @@ import {
JoinTable,
} from 'typeorm';
import { Permission } from './permission.entity';
import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity';
/** ขอบเขตของบทบาท */
export enum RoleScope {
GLOBAL = 'Global',
ORGANIZATION = 'Organization',
@@ -14,8 +19,15 @@ export enum RoleScope {
CONTRACT = 'Contract',
}
/**
* Entity สำหรับตาราง roles
*
* @remarks
* - Internal PK: roleId (INT) — ห้าม expose ใน API (ADR-019)
* - Public ID: publicId (UUID) — ใช้ใน API Response และ distribution_recipients (delta-11)
*/
@Entity('roles')
export class Role {
export class Role extends UuidBaseEntity {
@PrimaryGeneratedColumn({ name: 'role_id' })
roleId!: number;