260324:2133 Refactor correspondence & rfa
This commit is contained in:
@@ -56,6 +56,7 @@ export default tseslint.config(
|
||||
files: ['**/*.spec.ts', '**/*.e2e-spec.ts'],
|
||||
rules: {
|
||||
'@typescript-eslint/unbound-method': 'off',
|
||||
'@typescript-eslint/no-unsafe-assignment': 'off',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
|
||||
import { WinstonModule } from 'nest-winston';
|
||||
import { CacheModule } from '@nestjs/cache-manager';
|
||||
@@ -149,6 +150,9 @@ import { MigrationModule } from './modules/migration/migration.module';
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
|
||||
// ⏰ Scheduler (Cron Jobs)
|
||||
ScheduleModule.forRoot(),
|
||||
|
||||
// 📊 Monitoring & Resilience
|
||||
MonitoringModule,
|
||||
ResilienceModule,
|
||||
|
||||
@@ -95,13 +95,23 @@ export class CirculationService {
|
||||
}
|
||||
|
||||
async findAll(searchDto: SearchCirculationDto, user: User) {
|
||||
const { status, page = 1, limit = 20 } = searchDto;
|
||||
const { status, correspondenceUuid, page = 1, limit = 20 } = searchDto;
|
||||
const query = this.circulationRepo
|
||||
.createQueryBuilder('c')
|
||||
.leftJoinAndSelect('c.creator', 'creator')
|
||||
.where('c.organizationId = :orgId', {
|
||||
.leftJoinAndSelect('c.routings', 'routings')
|
||||
.leftJoinAndSelect('routings.assignee', 'assignee')
|
||||
.leftJoinAndSelect('c.correspondence', 'correspondence');
|
||||
|
||||
if (correspondenceUuid) {
|
||||
query.where('correspondence.uuid = :corrUuid', {
|
||||
corrUuid: correspondenceUuid,
|
||||
});
|
||||
} else {
|
||||
query.where('c.organizationId = :orgId', {
|
||||
orgId: user.primaryOrganizationId,
|
||||
});
|
||||
}
|
||||
|
||||
if (status) {
|
||||
query.andWhere('c.statusCode = :status', { status });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsInt, IsOptional, IsString } from 'class-validator';
|
||||
import { IsInt, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class SearchCirculationDto {
|
||||
@@ -6,6 +6,10 @@ export class SearchCirculationDto {
|
||||
@IsString()
|
||||
search?: string; // ค้นหาจาก Subject หรือ No.
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID('all')
|
||||
correspondenceUuid?: string; // กรองตาม correspondence UUID (ADR-019)
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
status?: string; // OPEN, COMPLETED
|
||||
|
||||
@@ -9,6 +9,9 @@ import { WorkflowEngineService } from '../workflow-engine/workflow-engine.servic
|
||||
import { CorrespondenceRevision } from './entities/correspondence-revision.entity';
|
||||
import { CorrespondenceStatus } from './entities/correspondence-status.entity';
|
||||
import { Correspondence } from './entities/correspondence.entity';
|
||||
import { CorrespondenceRecipient } from './entities/correspondence-recipient.entity';
|
||||
import { NotificationService } from '../notification/notification.service';
|
||||
import { UserService } from '../user/user.service';
|
||||
|
||||
@Injectable()
|
||||
export class CorrespondenceWorkflowService {
|
||||
@@ -23,7 +26,11 @@ export class CorrespondenceWorkflowService {
|
||||
private readonly revisionRepo: Repository<CorrespondenceRevision>,
|
||||
@InjectRepository(CorrespondenceStatus)
|
||||
private readonly statusRepo: Repository<CorrespondenceStatus>,
|
||||
private readonly dataSource: DataSource
|
||||
@InjectRepository(CorrespondenceRecipient)
|
||||
private readonly recipientRepo: Repository<CorrespondenceRecipient>,
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly notificationService: NotificationService,
|
||||
private readonly userService: UserService
|
||||
) {}
|
||||
|
||||
async submitWorkflow(
|
||||
@@ -82,6 +89,39 @@ export class CorrespondenceWorkflowService {
|
||||
|
||||
await queryRunner.commitTransaction();
|
||||
|
||||
// Notify TO recipient org users (fire-and-forget)
|
||||
const corrForNotify = revision.correspondence;
|
||||
if (corrForNotify) {
|
||||
void this.recipientRepo
|
||||
.find({
|
||||
where: {
|
||||
correspondenceId: corrForNotify.id,
|
||||
recipientType: 'TO',
|
||||
},
|
||||
})
|
||||
.then(async (recipients) => {
|
||||
for (const r of recipients) {
|
||||
const targetUserId = await this.userService.findDocControlIdByOrg(
|
||||
r.recipientOrganizationId
|
||||
);
|
||||
if (targetUserId) {
|
||||
await this.notificationService.send({
|
||||
userId: targetUserId,
|
||||
title: 'New Correspondence Received',
|
||||
message: `${corrForNotify.correspondenceNumber} has been submitted to your organization.`,
|
||||
type: 'EMAIL',
|
||||
entityType: 'correspondence',
|
||||
entityId: revision.correspondenceId,
|
||||
link: `/correspondences/${corrForNotify.uuid}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err: Error) =>
|
||||
this.logger.warn(`Submit notification failed: ${err.message}`)
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
instanceId: instance.id,
|
||||
currentState: transitionResult.nextState,
|
||||
|
||||
@@ -9,7 +9,11 @@ import {
|
||||
Query,
|
||||
Delete,
|
||||
Put,
|
||||
ParseIntPipe,
|
||||
Res,
|
||||
HttpCode,
|
||||
} from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
@@ -24,6 +28,8 @@ import { SubmitCorrespondenceDto } from './dto/submit-correspondence.dto';
|
||||
import { WorkflowActionDto } from './dto/workflow-action.dto';
|
||||
import { AddReferenceDto } from './dto/add-reference.dto';
|
||||
import { SearchCorrespondenceDto } from './dto/search-correspondence.dto';
|
||||
import { CancelCorrespondenceDto } from './dto/cancel-correspondence.dto';
|
||||
import { BulkCancelDto } from './dto/bulk-cancel.dto';
|
||||
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { RbacGuard } from '../../common/guards/rbac.guard';
|
||||
@@ -199,4 +205,80 @@ export class CorrespondenceController {
|
||||
const target = await this.correspondenceService.findOneByUuid(targetUuid);
|
||||
return this.correspondenceService.removeReference(corr.id, target.id);
|
||||
}
|
||||
|
||||
@Get(':uuid/tags')
|
||||
@ApiOperation({ summary: 'Get tags for a correspondence' })
|
||||
@RequirePermission('document.view')
|
||||
async getTags(@Param('uuid', ParseUuidPipe) uuid: string) {
|
||||
const corr = await this.correspondenceService.findOneByUuid(uuid);
|
||||
return this.correspondenceService.getTags(corr.id);
|
||||
}
|
||||
|
||||
@Post(':uuid/tags/:tagId')
|
||||
@ApiOperation({ summary: 'Add tag to a correspondence' })
|
||||
@RequirePermission('document.edit')
|
||||
async addTag(
|
||||
@Param('uuid', ParseUuidPipe) uuid: string,
|
||||
@Param('tagId', ParseIntPipe) tagId: number
|
||||
) {
|
||||
const corr = await this.correspondenceService.findOneByUuid(uuid);
|
||||
return this.correspondenceService.addTag(corr.id, tagId);
|
||||
}
|
||||
|
||||
@Delete(':uuid/tags/:tagId')
|
||||
@ApiOperation({ summary: 'Remove tag from a correspondence' })
|
||||
@RequirePermission('document.edit')
|
||||
async removeTag(
|
||||
@Param('uuid', ParseUuidPipe) uuid: string,
|
||||
@Param('tagId', ParseIntPipe) tagId: number
|
||||
) {
|
||||
const corr = await this.correspondenceService.findOneByUuid(uuid);
|
||||
return this.correspondenceService.removeTag(corr.id, tagId);
|
||||
}
|
||||
|
||||
@Post('bulk-cancel')
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: 'Bulk cancel correspondences (Org Admin+)' })
|
||||
@RequirePermission('correspondence.cancel')
|
||||
@Audit('correspondence.bulk_cancel', 'correspondence')
|
||||
async bulkCancel(
|
||||
@Body() dto: BulkCancelDto,
|
||||
@Request() req: RequestWithUser
|
||||
) {
|
||||
return this.correspondenceService.bulkCancel(
|
||||
dto.uuids,
|
||||
dto.reason,
|
||||
req.user
|
||||
);
|
||||
}
|
||||
|
||||
@Get('export-csv')
|
||||
@ApiOperation({ summary: 'Export correspondence list as CSV' })
|
||||
@RequirePermission('document.view')
|
||||
async exportCsv(
|
||||
@Query() searchDto: SearchCorrespondenceDto,
|
||||
@Res() res: Response
|
||||
) {
|
||||
const csv = await this.correspondenceService.exportCsv(searchDto);
|
||||
const filename = `correspondences-${new Date().toISOString().split('T')[0]}.csv`;
|
||||
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.send('\uFEFF' + csv);
|
||||
}
|
||||
|
||||
@Delete(':uuid')
|
||||
@ApiOperation({ summary: 'Cancel correspondence (Admin only)' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Correspondence cancelled successfully.',
|
||||
})
|
||||
@RequirePermission('correspondence.cancel')
|
||||
@Audit('correspondence.cancel', 'correspondence')
|
||||
async cancel(
|
||||
@Param('uuid', ParseUuidPipe) uuid: string,
|
||||
@Body() cancelDto: CancelCorrespondenceDto,
|
||||
@Request() req: RequestWithUser
|
||||
) {
|
||||
return this.correspondenceService.cancel(uuid, cancelDto.reason, req.user);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { CorrespondenceController } from './correspondence.controller';
|
||||
import { CorrespondenceService } from './correspondence.service';
|
||||
import { CorrespondenceWorkflowService } from './correspondence-workflow.service';
|
||||
import { DueDateReminderService } from './due-date-reminder.service';
|
||||
|
||||
// Entities
|
||||
import { Correspondence } from './entities/correspondence.entity';
|
||||
@@ -11,6 +12,7 @@ import { CorrespondenceType } from './entities/correspondence-type.entity';
|
||||
import { CorrespondenceStatus } from './entities/correspondence-status.entity';
|
||||
import { CorrespondenceReference } from './entities/correspondence-reference.entity';
|
||||
import { CorrespondenceRecipient } from './entities/correspondence-recipient.entity';
|
||||
import { CorrespondenceTag } from './entities/correspondence-tag.entity';
|
||||
import { Organization } from '../organization/entities/organization.entity';
|
||||
|
||||
// Dependent Modules
|
||||
@@ -20,6 +22,7 @@ import { UserModule } from '../user/user.module';
|
||||
import { WorkflowEngineModule } from '../workflow-engine/workflow-engine.module';
|
||||
import { SearchModule } from '../search/search.module';
|
||||
import { FileStorageModule } from '../../common/file-storage/file-storage.module';
|
||||
import { NotificationModule } from '../notification/notification.module';
|
||||
|
||||
/**
|
||||
* CorrespondenceModule
|
||||
@@ -36,6 +39,7 @@ import { FileStorageModule } from '../../common/file-storage/file-storage.module
|
||||
CorrespondenceStatus,
|
||||
CorrespondenceReference,
|
||||
CorrespondenceRecipient,
|
||||
CorrespondenceTag,
|
||||
Organization,
|
||||
]),
|
||||
DocumentNumberingModule,
|
||||
@@ -44,9 +48,14 @@ import { FileStorageModule } from '../../common/file-storage/file-storage.module
|
||||
WorkflowEngineModule,
|
||||
SearchModule,
|
||||
FileStorageModule,
|
||||
NotificationModule,
|
||||
],
|
||||
controllers: [CorrespondenceController],
|
||||
providers: [CorrespondenceService, CorrespondenceWorkflowService],
|
||||
providers: [
|
||||
CorrespondenceService,
|
||||
CorrespondenceWorkflowService,
|
||||
DueDateReminderService,
|
||||
],
|
||||
exports: [CorrespondenceService, CorrespondenceWorkflowService],
|
||||
})
|
||||
export class CorrespondenceModule {}
|
||||
|
||||
@@ -18,6 +18,8 @@ import { CorrespondenceType } from './entities/correspondence-type.entity';
|
||||
import { CorrespondenceStatus } from './entities/correspondence-status.entity';
|
||||
import { CorrespondenceReference } from './entities/correspondence-reference.entity';
|
||||
import { CorrespondenceRecipient } from './entities/correspondence-recipient.entity';
|
||||
import { CorrespondenceTag } from './entities/correspondence-tag.entity';
|
||||
import { Tag } from '../master/entities/tag.entity';
|
||||
import { User } from '../user/entities/user.entity';
|
||||
import { Organization } from '../organization/entities/organization.entity';
|
||||
|
||||
@@ -35,6 +37,7 @@ import { UserService } from '../user/user.service';
|
||||
import { SearchService } from '../search/search.service';
|
||||
import { FileStorageService } from '../../common/file-storage/file-storage.service';
|
||||
import { UuidResolverService } from '../../common/services/uuid-resolver.service';
|
||||
import { NotificationService } from '../notification/notification.service';
|
||||
|
||||
/**
|
||||
* CorrespondenceService - Document management (CRUD)
|
||||
@@ -58,6 +61,8 @@ export class CorrespondenceService {
|
||||
private statusRepo: Repository<CorrespondenceStatus>,
|
||||
@InjectRepository(CorrespondenceReference)
|
||||
private referenceRepo: Repository<CorrespondenceReference>,
|
||||
@InjectRepository(CorrespondenceTag)
|
||||
private tagRepo: Repository<CorrespondenceTag>,
|
||||
private numberingService: DocumentNumberingService,
|
||||
private jsonSchemaService: JsonSchemaService,
|
||||
private workflowEngine: WorkflowEngineService,
|
||||
@@ -65,10 +70,79 @@ export class CorrespondenceService {
|
||||
private dataSource: DataSource,
|
||||
private searchService: SearchService,
|
||||
private fileStorageService: FileStorageService,
|
||||
private uuidResolver: UuidResolverService
|
||||
private uuidResolver: UuidResolverService,
|
||||
private notificationService: NotificationService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Business Rule Validation: EC-CORR-003 - Correspondence to Self
|
||||
* Prevent external correspondence to same organization
|
||||
*/
|
||||
private async validateCorrespondenceRecipients(
|
||||
createDto: CreateCorrespondenceDto,
|
||||
user: User
|
||||
): Promise<void> {
|
||||
// Get user's organization
|
||||
let userOrgId = user.primaryOrganizationId;
|
||||
if (!userOrgId) {
|
||||
const fullUser = await this.userService.findOne(user.user_id);
|
||||
if (fullUser) {
|
||||
userOrgId = fullUser.primaryOrganizationId;
|
||||
}
|
||||
}
|
||||
|
||||
if (!userOrgId) {
|
||||
throw new BadRequestException(
|
||||
'User must belong to an organization to create documents'
|
||||
);
|
||||
}
|
||||
|
||||
// For impersonation, use the specified originator
|
||||
const originatorOrgId = createDto.originatorId
|
||||
? await this.uuidResolver.resolveOrganizationId(createDto.originatorId)
|
||||
: userOrgId;
|
||||
|
||||
// Check if it's internal communication
|
||||
if (createDto.isInternal) {
|
||||
// Internal communications should use Circulation instead
|
||||
throw new BadRequestException(
|
||||
'Internal communications should use Circulation Sheet instead of Correspondence'
|
||||
);
|
||||
}
|
||||
|
||||
// Validate recipients
|
||||
if (!createDto.recipients || createDto.recipients.length === 0) {
|
||||
throw new BadRequestException(
|
||||
'At least one recipient (TO or CC) is required'
|
||||
);
|
||||
}
|
||||
|
||||
const toRecipients = createDto.recipients.filter((r) => r.type === 'TO');
|
||||
const ccRecipients = createDto.recipients.filter((r) => r.type === 'CC');
|
||||
|
||||
if (toRecipients.length === 0 && ccRecipients.length === 0) {
|
||||
throw new BadRequestException(
|
||||
'At least one TO or CC recipient is required'
|
||||
);
|
||||
}
|
||||
|
||||
// Check for same organization correspondence
|
||||
for (const recipient of createDto.recipients) {
|
||||
const recipientOrgId = await this.uuidResolver.resolveOrganizationId(
|
||||
recipient.organizationId
|
||||
);
|
||||
|
||||
if (recipientOrgId === originatorOrgId) {
|
||||
throw new BadRequestException(
|
||||
'Cannot send correspondence to your own organization. Use Circulation Sheet for internal communication.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async create(createDto: CreateCorrespondenceDto, user: User) {
|
||||
// Business Rule Validation: EC-CORR-003 - Correspondence to Self
|
||||
await this.validateCorrespondenceRecipients(createDto, user);
|
||||
// ADR-019: Resolve UUID references to internal INT IDs
|
||||
const resolvedProjectId = await this.uuidResolver.resolveProjectId(
|
||||
createDto.projectId
|
||||
@@ -270,6 +344,7 @@ export class CorrespondenceService {
|
||||
// Fire-and-forget search indexing (non-blocking, void intentional)
|
||||
void this.searchService.indexDocument({
|
||||
id: savedCorr.id,
|
||||
uuid: savedCorr.uuid,
|
||||
type: 'correspondence',
|
||||
docNumber: docNumber.number,
|
||||
title: createDto.subject,
|
||||
@@ -300,6 +375,7 @@ export class CorrespondenceService {
|
||||
typeId,
|
||||
projectId,
|
||||
statusId,
|
||||
status,
|
||||
page = 1,
|
||||
limit = 10,
|
||||
} = searchDto;
|
||||
@@ -336,6 +412,10 @@ export class CorrespondenceService {
|
||||
query.andWhere('rev.statusId = :statusId', { statusId });
|
||||
}
|
||||
|
||||
if (status) {
|
||||
query.andWhere('status.statusCode = :status', { status });
|
||||
}
|
||||
|
||||
if (search) {
|
||||
query.andWhere(
|
||||
'(corr.correspondenceNumber LIKE :search OR rev.subject LIKE :search)',
|
||||
@@ -444,6 +524,45 @@ export class CorrespondenceService {
|
||||
}
|
||||
}
|
||||
|
||||
async getTags(id: number) {
|
||||
const rows = await this.tagRepo.find({
|
||||
where: { correspondenceId: id },
|
||||
relations: ['tag'],
|
||||
});
|
||||
return rows.map((r) => r.tag).filter(Boolean);
|
||||
}
|
||||
|
||||
async addTag(id: number, tagId: number) {
|
||||
const correspondence = await this.correspondenceRepo.findOne({
|
||||
where: { id },
|
||||
});
|
||||
if (!correspondence) {
|
||||
throw new NotFoundException(`Correspondence ${id} not found`);
|
||||
}
|
||||
|
||||
const tag = await this.dataSource.manager.findOne(Tag, {
|
||||
where: { id: tagId },
|
||||
});
|
||||
if (!tag) {
|
||||
throw new NotFoundException(`Tag ${tagId} not found`);
|
||||
}
|
||||
|
||||
const exists = await this.tagRepo.findOne({
|
||||
where: { correspondenceId: id, tagId },
|
||||
});
|
||||
if (exists) return exists;
|
||||
|
||||
const row = this.tagRepo.create({ correspondenceId: id, tagId });
|
||||
return this.tagRepo.save(row);
|
||||
}
|
||||
|
||||
async removeTag(id: number, tagId: number) {
|
||||
const result = await this.tagRepo.delete({ correspondenceId: id, tagId });
|
||||
if (result.affected === 0) {
|
||||
throw new NotFoundException('Tag assignment not found');
|
||||
}
|
||||
}
|
||||
|
||||
async getReferences(id: number) {
|
||||
const outgoing = await this.referenceRepo.find({
|
||||
where: { sourceId: id },
|
||||
@@ -690,7 +809,22 @@ export class CorrespondenceService {
|
||||
}
|
||||
}
|
||||
|
||||
return this.findOne(id);
|
||||
const updated = await this.findOne(id);
|
||||
|
||||
// Re-index updated document in Elasticsearch (fire-and-forget)
|
||||
void this.searchService.indexDocument({
|
||||
id: updated.id,
|
||||
uuid: updated.uuid,
|
||||
type: 'correspondence',
|
||||
docNumber: updated.correspondenceNumber,
|
||||
title: updateDto.subject ?? updated.revisions?.[0]?.subject,
|
||||
description: updateDto.description ?? updated.revisions?.[0]?.description,
|
||||
status: 'DRAFT',
|
||||
projectId: updated.projectId,
|
||||
createdAt: updated.createdAt,
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async previewDocumentNumber(createDto: CreateCorrespondenceDto, user: User) {
|
||||
@@ -757,4 +891,201 @@ export class CorrespondenceService {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Business Rule Implementation: EC-CORR-001 - Cancel Correspondence with Downstream Circulation
|
||||
* Cancel correspondence and handle related circulations
|
||||
*/
|
||||
async cancel(uuid: string, reason: string, user: User) {
|
||||
const correspondence = await this.findOneByUuid(uuid);
|
||||
|
||||
// Check if user has permission to cancel (Org Admin or Superadmin only)
|
||||
const permissions = await this.userService.getUserPermissions(user.user_id);
|
||||
const canCancel =
|
||||
permissions.includes('correspondence.cancel') ||
|
||||
permissions.includes('system.manage_all');
|
||||
|
||||
if (!canCancel) {
|
||||
throw new ForbiddenException(
|
||||
'Only administrators can cancel correspondences'
|
||||
);
|
||||
}
|
||||
|
||||
// Check if there are any active circulations
|
||||
const circulationRepo = this.dataSource.getRepository('Circulation');
|
||||
const activeCirculations = await circulationRepo.find({
|
||||
where: {
|
||||
correspondenceId: correspondence.id,
|
||||
status: 'OPEN',
|
||||
},
|
||||
});
|
||||
|
||||
const warningMessage =
|
||||
activeCirculations.length > 0
|
||||
? `There are ${activeCirculations.length} active circulation(s) for this correspondence. Canceling will force close all related circulations.`
|
||||
: '';
|
||||
|
||||
// Get the current revision to update status
|
||||
const currentRevision = await this.revisionRepo.findOne({
|
||||
where: {
|
||||
correspondenceId: correspondence.id,
|
||||
isCurrent: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!currentRevision) {
|
||||
throw new NotFoundException('Current revision not found');
|
||||
}
|
||||
|
||||
// Get cancelled status
|
||||
const cancelledStatus = await this.statusRepo.findOne({
|
||||
where: { statusCode: 'CANCELLED' },
|
||||
});
|
||||
|
||||
if (!cancelledStatus) {
|
||||
throw new InternalServerErrorException('CANCELLED status not found');
|
||||
}
|
||||
|
||||
const queryRunner = this.dataSource.createQueryRunner();
|
||||
await queryRunner.connect();
|
||||
await queryRunner.startTransaction();
|
||||
|
||||
try {
|
||||
// Update correspondence revision status to CANCELLED
|
||||
await queryRunner.manager.update(
|
||||
CorrespondenceRevision,
|
||||
currentRevision.id,
|
||||
{
|
||||
statusId: cancelledStatus.id,
|
||||
remarks: `Cancelled: ${reason}`,
|
||||
}
|
||||
);
|
||||
|
||||
// Force close all active circulations
|
||||
if (activeCirculations.length > 0) {
|
||||
await queryRunner.manager.update(
|
||||
'Circulation',
|
||||
{
|
||||
correspondenceId: correspondence.id,
|
||||
status: 'OPEN',
|
||||
},
|
||||
{
|
||||
status: 'FORCE_CLOSED',
|
||||
closedAt: new Date(),
|
||||
closedBy: user.user_id,
|
||||
closeReason: `Correspondence cancelled: ${reason}`,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
await queryRunner.commitTransaction();
|
||||
|
||||
// Re-index cancelled status in Elasticsearch (fire-and-forget)
|
||||
void this.searchService.indexDocument({
|
||||
id: correspondence.id,
|
||||
uuid: correspondence.uuid,
|
||||
type: 'correspondence',
|
||||
docNumber: correspondence.correspondenceNumber,
|
||||
title: currentRevision.subject,
|
||||
status: 'CANCELLED',
|
||||
projectId: correspondence.projectId,
|
||||
createdAt: correspondence.createdAt,
|
||||
});
|
||||
|
||||
// Notify originator's doc-control user about cancellation (fire-and-forget)
|
||||
if (correspondence.originatorId) {
|
||||
void this.userService
|
||||
.findDocControlIdByOrg(correspondence.originatorId)
|
||||
.then((targetUserId) => {
|
||||
if (targetUserId) {
|
||||
void this.notificationService.send({
|
||||
userId: targetUserId,
|
||||
title: 'Correspondence Cancelled',
|
||||
message: `${correspondence.correspondenceNumber} — ${currentRevision.subject} has been cancelled. Reason: ${reason}`,
|
||||
type: 'EMAIL',
|
||||
entityType: 'correspondence',
|
||||
entityId: correspondence.id,
|
||||
link: `/correspondences/${correspondence.uuid}`,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((err: Error) =>
|
||||
this.logger.warn(`Cancel notification failed: ${err.message}`)
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: warningMessage || 'Correspondence cancelled successfully',
|
||||
activeCirculationsCount: activeCirculations.length,
|
||||
};
|
||||
} catch (error) {
|
||||
await queryRunner.rollbackTransaction();
|
||||
this.logger.error(
|
||||
`Failed to cancel correspondence: ${(error as Error).message}`
|
||||
);
|
||||
throw error;
|
||||
} finally {
|
||||
await queryRunner.release();
|
||||
}
|
||||
}
|
||||
|
||||
async bulkCancel(
|
||||
uuids: string[],
|
||||
reason: string,
|
||||
user: User
|
||||
): Promise<{ succeeded: string[]; failed: string[] }> {
|
||||
const succeeded: string[] = [];
|
||||
const failed: string[] = [];
|
||||
|
||||
for (const uuid of uuids) {
|
||||
try {
|
||||
await this.cancel(uuid, reason, user);
|
||||
succeeded.push(uuid);
|
||||
} catch {
|
||||
failed.push(uuid);
|
||||
}
|
||||
}
|
||||
|
||||
return { succeeded, failed };
|
||||
}
|
||||
|
||||
async exportCsv(searchDto: SearchCorrespondenceDto): Promise<string> {
|
||||
const { data } = await this.findAll(searchDto);
|
||||
|
||||
const header = [
|
||||
'Document No.',
|
||||
'Rev',
|
||||
'Subject',
|
||||
'Type',
|
||||
'Status',
|
||||
'Project',
|
||||
'From',
|
||||
'Due Date',
|
||||
'Created At',
|
||||
];
|
||||
const rows = data.map((rev) => {
|
||||
const corr = rev.correspondence ?? (rev as unknown as Correspondence);
|
||||
return [
|
||||
this.escapeCsv(corr.correspondenceNumber ?? ''),
|
||||
this.escapeCsv(rev.revisionLabel ?? String(rev.revisionNumber ?? 0)),
|
||||
this.escapeCsv(rev.subject ?? ''),
|
||||
this.escapeCsv(corr.type?.typeCode ?? ''),
|
||||
this.escapeCsv(rev.status?.statusCode ?? ''),
|
||||
this.escapeCsv(corr.project?.projectCode ?? ''),
|
||||
this.escapeCsv(corr.originator?.organizationCode ?? ''),
|
||||
rev.dueDate ? new Date(rev.dueDate).toISOString().split('T')[0] : '',
|
||||
new Date(rev.createdAt).toISOString().split('T')[0],
|
||||
].join(',');
|
||||
});
|
||||
|
||||
return [header.join(','), ...rows].join('\n');
|
||||
}
|
||||
|
||||
private escapeCsv(value: string): string {
|
||||
if (value.includes(',') || value.includes('"') || value.includes('\n')) {
|
||||
return `"${value.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import {
|
||||
IsArray,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MinLength,
|
||||
ArrayMinSize,
|
||||
} from 'class-validator';
|
||||
|
||||
export class BulkCancelDto {
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@IsUUID('all', { each: true })
|
||||
uuids!: string[];
|
||||
|
||||
@IsString()
|
||||
@MinLength(3)
|
||||
reason!: string;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, IsNotEmpty } from 'class-validator';
|
||||
|
||||
export class CancelCorrespondenceDto {
|
||||
@ApiProperty({
|
||||
description: 'Reason for cancelling the correspondence',
|
||||
example: 'Document was created in error',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
reason!: string;
|
||||
}
|
||||
@@ -28,6 +28,14 @@ export class SearchCorrespondenceDto {
|
||||
@IsInt()
|
||||
statusId?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Filter by Status code (e.g. DRAFT, IN_REVIEW, APPROVED, CANCELLED)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
status?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Revision Filter: CURRENT (default), ALL, OLD',
|
||||
})
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { DueDateReminderService } from './due-date-reminder.service';
|
||||
import { CorrespondenceRevision } from './entities/correspondence-revision.entity';
|
||||
import { NotificationService } from '../notification/notification.service';
|
||||
import { UserService } from '../user/user.service';
|
||||
|
||||
describe('DueDateReminderService', () => {
|
||||
let service: DueDateReminderService;
|
||||
let revisionRepo: { find: jest.Mock };
|
||||
let notificationService: { send: jest.Mock };
|
||||
let userService: { findDocControlIdByOrg: jest.Mock };
|
||||
|
||||
const mockRevisionRepo = () => ({
|
||||
find: jest.fn(),
|
||||
});
|
||||
|
||||
const mockNotificationService = () => ({
|
||||
send: jest.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
const mockUserService = () => ({
|
||||
findDocControlIdByOrg: jest.fn(),
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
DueDateReminderService,
|
||||
{
|
||||
provide: getRepositoryToken(CorrespondenceRevision),
|
||||
useFactory: mockRevisionRepo,
|
||||
},
|
||||
{
|
||||
provide: NotificationService,
|
||||
useFactory: mockNotificationService,
|
||||
},
|
||||
{
|
||||
provide: UserService,
|
||||
useFactory: mockUserService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<DueDateReminderService>(DueDateReminderService);
|
||||
revisionRepo = module.get(getRepositoryToken(CorrespondenceRevision));
|
||||
notificationService = module.get(NotificationService);
|
||||
userService = module.get(UserService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('sendDueDateReminders', () => {
|
||||
it('should do nothing when no revisions are approaching due date', async () => {
|
||||
revisionRepo.find.mockResolvedValue([]);
|
||||
|
||||
await service.sendDueDateReminders();
|
||||
|
||||
expect(notificationService.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip revisions with no correspondence', async () => {
|
||||
revisionRepo.find.mockResolvedValue([
|
||||
{
|
||||
correspondence: null,
|
||||
status: { statusCode: 'DRAFT' },
|
||||
dueDate: new Date(),
|
||||
},
|
||||
]);
|
||||
|
||||
await service.sendDueDateReminders();
|
||||
|
||||
expect(notificationService.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip cancelled correspondences', async () => {
|
||||
revisionRepo.find.mockResolvedValue([
|
||||
{
|
||||
correspondence: {
|
||||
id: 1,
|
||||
uuid: 'uuid-1',
|
||||
correspondenceNumber: 'LC-001',
|
||||
originatorId: 10,
|
||||
},
|
||||
status: { statusCode: 'CANCELLED' },
|
||||
subject: 'Test',
|
||||
dueDate: new Date(Date.now() + 86400000),
|
||||
},
|
||||
]);
|
||||
|
||||
await service.sendDueDateReminders();
|
||||
|
||||
expect(notificationService.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip closed (CLBOWN) correspondences', async () => {
|
||||
revisionRepo.find.mockResolvedValue([
|
||||
{
|
||||
correspondence: {
|
||||
id: 1,
|
||||
uuid: 'uuid-1',
|
||||
correspondenceNumber: 'LC-001',
|
||||
originatorId: 10,
|
||||
},
|
||||
status: { statusCode: 'CLBOWN' },
|
||||
subject: 'Test',
|
||||
dueDate: new Date(Date.now() + 86400000),
|
||||
},
|
||||
]);
|
||||
|
||||
await service.sendDueDateReminders();
|
||||
|
||||
expect(notificationService.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip when no doc-control user found for org', async () => {
|
||||
revisionRepo.find.mockResolvedValue([
|
||||
{
|
||||
correspondence: {
|
||||
id: 1,
|
||||
uuid: 'uuid-1',
|
||||
correspondenceNumber: 'LC-001',
|
||||
originatorId: 10,
|
||||
},
|
||||
status: { statusCode: 'DRAFT' },
|
||||
subject: 'Test Subject',
|
||||
dueDate: new Date(Date.now() + 86400000),
|
||||
},
|
||||
]);
|
||||
userService.findDocControlIdByOrg.mockResolvedValue(null);
|
||||
|
||||
await service.sendDueDateReminders();
|
||||
|
||||
expect(notificationService.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should send EMAIL notification for a valid approaching due date', async () => {
|
||||
const dueDate = new Date(Date.now() + 86400000 * 2); // 2 days later
|
||||
revisionRepo.find.mockResolvedValue([
|
||||
{
|
||||
correspondence: {
|
||||
id: 5,
|
||||
uuid: 'corr-uuid-1',
|
||||
correspondenceNumber: 'LC-TEST-001',
|
||||
originatorId: 10,
|
||||
},
|
||||
status: { statusCode: 'SUBOWN' },
|
||||
subject: 'Design Review Request',
|
||||
dueDate,
|
||||
},
|
||||
]);
|
||||
userService.findDocControlIdByOrg.mockResolvedValue(42);
|
||||
|
||||
await service.sendDueDateReminders();
|
||||
|
||||
expect(userService.findDocControlIdByOrg).toHaveBeenCalledWith(10);
|
||||
expect(notificationService.send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 42,
|
||||
title: 'Due Date Approaching',
|
||||
type: 'EMAIL',
|
||||
entityType: 'correspondence',
|
||||
entityId: 5,
|
||||
link: '/correspondences/corr-uuid-1',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle errors per revision without stopping other notifications', async () => {
|
||||
const dueDate = new Date(Date.now() + 86400000);
|
||||
revisionRepo.find.mockResolvedValue([
|
||||
{
|
||||
correspondence: {
|
||||
id: 1,
|
||||
uuid: 'uuid-1',
|
||||
correspondenceNumber: 'LC-001',
|
||||
originatorId: 10,
|
||||
},
|
||||
status: { statusCode: 'SUBOWN' },
|
||||
subject: 'First',
|
||||
dueDate,
|
||||
},
|
||||
{
|
||||
correspondence: {
|
||||
id: 2,
|
||||
uuid: 'uuid-2',
|
||||
correspondenceNumber: 'LC-002',
|
||||
originatorId: 20,
|
||||
},
|
||||
status: { statusCode: 'SUBOWN' },
|
||||
subject: 'Second',
|
||||
dueDate,
|
||||
},
|
||||
]);
|
||||
userService.findDocControlIdByOrg
|
||||
.mockResolvedValueOnce(42)
|
||||
.mockRejectedValueOnce(new Error('DB error'));
|
||||
|
||||
await service.sendDueDateReminders();
|
||||
|
||||
expect(notificationService.send).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should correctly calculate daysLeft in the message', async () => {
|
||||
const dueDate = new Date(Date.now() + 86400000); // exactly 1 day
|
||||
revisionRepo.find.mockResolvedValue([
|
||||
{
|
||||
correspondence: {
|
||||
id: 3,
|
||||
uuid: 'uuid-3',
|
||||
correspondenceNumber: 'LC-003',
|
||||
originatorId: 5,
|
||||
},
|
||||
status: { statusCode: 'DRAFT' },
|
||||
subject: 'Urgent Document',
|
||||
dueDate,
|
||||
},
|
||||
]);
|
||||
userService.findDocControlIdByOrg.mockResolvedValue(99);
|
||||
|
||||
await service.sendDueDateReminders();
|
||||
|
||||
expect(notificationService.send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining('1 day'),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, Between } from 'typeorm';
|
||||
import { CorrespondenceRevision } from './entities/correspondence-revision.entity';
|
||||
import { NotificationService } from '../notification/notification.service';
|
||||
import { UserService } from '../user/user.service';
|
||||
|
||||
@Injectable()
|
||||
export class DueDateReminderService {
|
||||
private readonly logger = new Logger(DueDateReminderService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(CorrespondenceRevision)
|
||||
private revisionRepo: Repository<CorrespondenceRevision>,
|
||||
private notificationService: NotificationService,
|
||||
private userService: UserService
|
||||
) {}
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_8AM)
|
||||
async sendDueDateReminders() {
|
||||
this.logger.log('Running due date reminder check...');
|
||||
|
||||
const now = new Date();
|
||||
const threeDaysLater = new Date(now);
|
||||
threeDaysLater.setDate(threeDaysLater.getDate() + 3);
|
||||
|
||||
const revisions = await this.revisionRepo.find({
|
||||
where: {
|
||||
isCurrent: true,
|
||||
dueDate: Between(now, threeDaysLater),
|
||||
},
|
||||
relations: ['correspondence', 'correspondence.originator', 'status'],
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Found ${revisions.length} correspondences approaching due date`
|
||||
);
|
||||
|
||||
for (const revision of revisions) {
|
||||
const corr = revision.correspondence;
|
||||
if (!corr) continue;
|
||||
|
||||
const statusCode = revision.status?.statusCode ?? '';
|
||||
if (statusCode === 'CANCELLED' || statusCode === 'CLBOWN') continue;
|
||||
|
||||
if (!corr.originatorId) continue;
|
||||
|
||||
try {
|
||||
const targetUserId = await this.userService.findDocControlIdByOrg(
|
||||
corr.originatorId
|
||||
);
|
||||
if (!targetUserId) continue;
|
||||
|
||||
const daysLeft = Math.ceil(
|
||||
(revision.dueDate!.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
|
||||
await this.notificationService.send({
|
||||
userId: targetUserId,
|
||||
title: 'Due Date Approaching',
|
||||
message: `${corr.correspondenceNumber} — "${revision.subject}" is due in ${daysLeft} day${daysLeft === 1 ? '' : 's'}.`,
|
||||
type: 'EMAIL',
|
||||
entityType: 'correspondence',
|
||||
entityId: corr.id,
|
||||
link: `/correspondences/${corr.uuid}`,
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Due date reminder failed for ${corr.correspondenceNumber}: ${(err as Error).message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Entity, PrimaryColumn, ManyToOne, JoinColumn } from 'typeorm';
|
||||
import { Correspondence } from './correspondence.entity';
|
||||
import { Tag } from '../../master/entities/tag.entity';
|
||||
|
||||
@Entity('correspondence_tags')
|
||||
export class CorrespondenceTag {
|
||||
@PrimaryColumn({ name: 'correspondence_id' })
|
||||
correspondenceId!: number;
|
||||
|
||||
@PrimaryColumn({ name: 'tag_id' })
|
||||
tagId!: number;
|
||||
|
||||
@ManyToOne(() => Correspondence, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'correspondence_id' })
|
||||
correspondence?: Correspondence;
|
||||
|
||||
@ManyToOne(() => Tag, { onDelete: 'CASCADE', eager: true })
|
||||
@JoinColumn({ name: 'tag_id' })
|
||||
tag?: Tag;
|
||||
}
|
||||
@@ -10,6 +10,10 @@ export class SearchQueryDto {
|
||||
@IsOptional()
|
||||
type?: string; // กรองประเภท: 'rfa', 'correspondence', 'drawing'
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
status?: string; // กรองสถานะ: 'DRAFT', 'SUBOWN', 'CLBOWN', 'CANCELLED', ...
|
||||
|
||||
@IsInt()
|
||||
@Type(() => Number)
|
||||
@IsOptional()
|
||||
|
||||
@@ -108,7 +108,7 @@ export class SearchService implements OnModuleInit {
|
||||
* ค้นหาเอกสาร (Full-text Search)
|
||||
*/
|
||||
async search(queryDto: SearchQueryDto) {
|
||||
const { q, type, projectId, page = 1, limit = 20 } = queryDto;
|
||||
const { q, type, status, projectId, page = 1, limit = 20 } = queryDto;
|
||||
const from = (page - 1) * limit;
|
||||
|
||||
// Early fallback if Elasticsearch is not available
|
||||
@@ -135,6 +135,7 @@ export class SearchService implements OnModuleInit {
|
||||
// 2. Filter logic
|
||||
const filterQueries: Record<string, unknown>[] = [];
|
||||
if (type) filterQueries.push({ term: { type } });
|
||||
if (status) filterQueries.push({ term: { status } });
|
||||
if (projectId) filterQueries.push({ term: { projectId } });
|
||||
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user