260324:2133 Refactor correspondence & rfa
CI / CD Pipeline / build (push) Failing after 17m3s
CI / CD Pipeline / deploy (push) Has been skipped

This commit is contained in:
admin
2026-03-24 21:33:59 +07:00
parent 42fc9fa502
commit aa82b890a5
42 changed files with 2617 additions and 233 deletions
+1
View File
@@ -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',
},
}
);
+4
View File
@@ -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()
+2 -1
View File
@@ -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 {