690514:2019 204-rfa-approval-refactor #01
This commit is contained in:
@@ -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,
|
||||
},
|
||||
}));
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user