Files
lcbp3/backend/src/modules/correspondence/correspondence.service.ts
T
admin 42a6d24318
CI / CD Pipeline / build (push) Successful in 5m13s
CI / CD Pipeline / deploy (push) Successful in 4m18s
690503:1547 Update #01
2026-05-03 15:47:59 +07:00

1191 lines
40 KiB
TypeScript

// File: src/modules/correspondence/correspondence.service.ts
import { Injectable, Logger, Inject, forwardRef } from '@nestjs/common';
import {
BusinessException,
NotFoundException,
PermissionException,
SystemException,
ValidationException,
} from '../../common/exceptions';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';
// Entities
import { Correspondence } from './entities/correspondence.entity';
import { CorrespondenceRevision } from './entities/correspondence-revision.entity';
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';
import { CorrespondenceRevisionAttachment } from './entities/correspondence-revision-attachment.entity';
// DTOs
import { CreateCorrespondenceDto } from './dto/create-correspondence.dto';
import { UpdateCorrespondenceDto } from './dto/update-correspondence.dto';
import { AddReferenceDto } from './dto/add-reference.dto';
import { SearchCorrespondenceDto } from './dto/search-correspondence.dto';
// Services
import { DocumentNumberingService } from '../document-numbering/services/document-numbering.service';
import { JsonSchemaService } from '../json-schema/json-schema.service';
import { WorkflowEngineService } from '../workflow-engine/workflow-engine.service';
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';
import { CirculationService } from '../circulation/circulation.service';
import { Circulation } from '../circulation/entities/circulation.entity';
import { CirculationRouting } from '../circulation/entities/circulation-routing.entity';
/**
* CorrespondenceService - Document management (CRUD)
*/
interface ResolvedRecipient {
organizationId: number;
type: 'TO' | 'CC';
}
@Injectable()
export class CorrespondenceService {
private readonly logger = new Logger(CorrespondenceService.name);
private async hasSystemManageAllPermission(userId: number): Promise<boolean> {
const permissions = await this.userService.getUserPermissions(userId);
return permissions.includes('system.manage_all');
}
/**
* Business Rule: Revision Label Strategy
* - RFA, RFI: Use alphabet starting with 'A' (A, B, C...)
* - Other types (LETTER, MEMO, etc.): Use numeric (null for first, then 1, 2, 3...)
*/
private getInitialRevisionLabel(typeCode: string): string | undefined {
const alphabetTypes = ['RFA', 'RFI'];
if (alphabetTypes.includes(typeCode.toUpperCase())) {
return 'A'; // Alphabet for RFA, RFI
}
return undefined; // Numeric (no label for revision 0)
}
constructor(
@InjectRepository(Correspondence)
private correspondenceRepo: Repository<Correspondence>,
@InjectRepository(CorrespondenceRevision)
private revisionRepo: Repository<CorrespondenceRevision>,
@InjectRepository(CorrespondenceType)
private typeRepo: Repository<CorrespondenceType>,
@InjectRepository(CorrespondenceStatus)
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,
private userService: UserService,
private dataSource: DataSource,
private searchService: SearchService,
private fileStorageService: FileStorageService,
private uuidResolver: UuidResolverService,
private notificationService: NotificationService,
@InjectRepository(CorrespondenceRevisionAttachment)
private revAttachRepo: Repository<CorrespondenceRevisionAttachment>,
@Inject(forwardRef(() => CirculationService))
private circulationService: CirculationService
) {}
/**
* 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) {
if (createDto.originatorId) {
const canManageAll = await this.hasSystemManageAllPermission(
user.user_id
);
if (canManageAll) {
userOrgId = await this.uuidResolver.resolveOrganizationId(
createDto.originatorId
);
}
}
if (!userOrgId) {
throw new ValidationException(
'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 BusinessException(
'INVALID_DOCUMENT_TYPE',
'Internal communications should use Circulation Sheet instead of Correspondence',
'การสื่อสารภายในควรใช้ Circulation Sheet แทน Correspondence',
['ใช้ Circulation Sheet สำหรับการสื่อสารภายในองค์กร']
);
}
// Validate recipients
if (!createDto.recipients || createDto.recipients.length === 0) {
throw new ValidationException(
'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 ValidationException(
'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 BusinessException(
'CORRESPONDENCE_TO_SELF',
'Cannot send correspondence to your own organization',
'ไม่สามารถส่งเอกสารถึงองค์กรของตัวเองได้ ใช้ Circulation Sheet แทน',
['ใช้ Circulation Sheet สำหรับการสื่อสารภายใน']
);
}
}
}
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
);
const resolvedOriginatorId = createDto.originatorId
? await this.uuidResolver.resolveOrganizationId(createDto.originatorId)
: undefined;
const resolvedRecipients = createDto.recipients
? await Promise.all(
createDto.recipients.map(
async (r): Promise<ResolvedRecipient> => ({
organizationId: await this.uuidResolver.resolveOrganizationId(
r.organizationId
),
type: r.type,
})
)
)
: undefined;
const type = await this.typeRepo.findOne({
where: { id: createDto.typeId },
});
if (!type)
throw new NotFoundException('Document Type', String(createDto.typeId));
const statusDraft = await this.statusRepo.findOne({
where: { statusCode: 'DRAFT' },
});
if (!statusDraft) {
throw new SystemException('Status DRAFT not found in Master Data');
}
let userOrgId = user.primaryOrganizationId;
if (!userOrgId) {
const fullUser = await this.userService.findOne(user.user_id);
if (fullUser) {
userOrgId = fullUser.primaryOrganizationId;
}
}
// Impersonation Logic
if (resolvedOriginatorId && resolvedOriginatorId !== userOrgId) {
const canManageAll = await this.hasSystemManageAllPermission(
user.user_id
);
if (!canManageAll) {
throw new PermissionException(
'correspondence',
'create on behalf of other organization'
);
}
userOrgId = resolvedOriginatorId;
}
if (!userOrgId) {
throw new ValidationException(
'User must belong to an organization to create documents'
);
}
if (createDto.details) {
try {
await this.jsonSchemaService.validate(type.typeCode, createDto.details);
} catch (error: unknown) {
this.logger.warn(
`Schema validation warning for ${type.typeCode}: ${(error as Error).message}`
);
}
}
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
// [Fix #6] Fetch real ORG Code from Organization entity
const originatorOrg = await this.dataSource.manager.findOne(
Organization,
{
where: { id: userOrgId },
}
);
const orgCode = originatorOrg?.organizationCode ?? 'UNK';
// [v1.5.1] Extract recipient organization from recipients array (Primary TO)
const toRecipient = resolvedRecipients?.find((r) => r.type === 'TO');
const recipientOrganizationId = toRecipient?.organizationId;
let recipientCode = '';
if (recipientOrganizationId) {
const recOrg = await this.dataSource.manager.findOne(Organization, {
where: { id: recipientOrganizationId },
});
if (recOrg) recipientCode = recOrg.organizationCode;
}
const docNumber = await this.numberingService.generateNextNumber({
projectId: resolvedProjectId,
originatorOrganizationId: userOrgId,
typeId: createDto.typeId,
disciplineId: createDto.disciplineId,
subTypeId: createDto.subTypeId,
recipientOrganizationId, // [v1.5.1] Pass recipient for document number format
year: new Date().getFullYear(),
customTokens: {
TYPE_CODE: type.typeCode,
ORG_CODE: orgCode,
RECIPIENT_CODE: recipientCode,
REC_CODE: recipientCode,
},
});
const correspondence = queryRunner.manager.create(Correspondence, {
correspondenceNumber: docNumber.number,
correspondenceTypeId: createDto.typeId,
disciplineId: createDto.disciplineId,
projectId: resolvedProjectId,
originatorId: userOrgId,
isInternal: createDto.isInternal || false,
createdBy: user.user_id,
});
const savedCorr = await queryRunner.manager.save(correspondence);
const revision = queryRunner.manager.create(CorrespondenceRevision, {
correspondenceId: savedCorr.id,
revisionNumber: 0,
revisionLabel: this.getInitialRevisionLabel(type.typeCode),
isCurrent: true,
statusId: statusDraft.id,
subject: createDto.subject,
body: createDto.body,
remarks: createDto.remarks,
dueDate: createDto.dueDate ? new Date(createDto.dueDate) : undefined,
documentDate: createDto.documentDate
? new Date(createDto.documentDate)
: undefined,
issuedDate: createDto.issuedDate
? new Date(createDto.issuedDate)
: undefined,
receivedDate: createDto.receivedDate
? new Date(createDto.receivedDate)
: undefined,
description: createDto.description,
details: createDto.details,
createdBy: user.user_id,
schemaVersion: 1,
});
await queryRunner.manager.save(revision);
// Save Recipients (using resolved INT IDs)
if (resolvedRecipients && resolvedRecipients.length > 0) {
const recipients = resolvedRecipients.map((r) =>
queryRunner.manager.create(CorrespondenceRecipient, {
correspondenceId: savedCorr.id,
recipientOrganizationId: r.organizationId,
recipientType: r.type,
})
);
await queryRunner.manager.save(recipients);
}
// Commit attachments from Temp → Permanent (Two-Phase Storage)
if (createDto.attachmentTempIds?.length) {
const issueDate = createDto.issuedDate
? new Date(createDto.issuedDate)
: createDto.documentDate
? new Date(createDto.documentDate)
: undefined;
// [FIX v1.8.1] commit ได้ Attachment records กลับมา → บันทึก junction
const committed = await this.fileStorageService.commit(
createDto.attachmentTempIds,
{ issueDate, documentType: 'Correspondence' }
);
if (committed.length > 0) {
const links = committed.map((att, idx) =>
queryRunner.manager.create(CorrespondenceRevisionAttachment, {
correspondenceRevisionId: revision.id,
attachmentId: att.id,
isMainDocument: idx === 0, // ไฟล์แรกเป็น main document
})
);
await queryRunner.manager.save(
CorrespondenceRevisionAttachment,
links
);
}
}
await queryRunner.commitTransaction();
// Start Workflow Instance (non-blocking)
// All correspondence types use CORRESPONDENCE_FLOW_V1 (type code is NOT a separate workflow)
try {
let corrContractId: number | null = null;
if (createDto.disciplineId) {
const disciplineRows = await this.dataSource.query<
[{ contract_id: number }]
>('SELECT contract_id FROM disciplines WHERE id = ? LIMIT 1', [
createDto.disciplineId,
]);
corrContractId = disciplineRows[0]?.contract_id ?? null;
}
await this.workflowEngine.createInstance(
'CORRESPONDENCE_FLOW_V1',
'correspondence',
savedCorr.id.toString(),
{
projectId: resolvedProjectId,
contractId: corrContractId,
originatorId: userOrgId,
disciplineId: createDto.disciplineId,
initiatorId: user.user_id,
} as Record<string, unknown>
);
} catch (error: unknown) {
this.logger.warn(
`Workflow not started for ${docNumber.number}: ${(error as Error).message}`
);
}
// Fire-and-forget search indexing (non-blocking, void intentional)
void this.searchService.indexDocument({
id: savedCorr.id,
publicId: savedCorr.publicId,
type: 'correspondence',
docNumber: docNumber.number,
title: createDto.subject,
description: createDto.description,
status: 'DRAFT',
projectId: resolvedProjectId,
createdAt: new Date(),
});
return {
...savedCorr,
currentRevision: revision,
};
} catch (err) {
await queryRunner.rollbackTransaction();
this.logger.error(
`Failed to create correspondence: ${(err as Error).message}`
);
throw err;
} finally {
await queryRunner.release();
}
}
async findAll(searchDto: SearchCorrespondenceDto = {}) {
const {
search,
typeId,
projectId,
statusId,
status,
page = 1,
limit = 10,
} = searchDto;
const skip = (page - 1) * limit;
// Change: Query from Revision Repo
const query = this.revisionRepo
.createQueryBuilder('rev')
.leftJoinAndSelect('rev.correspondence', 'corr')
.leftJoinAndSelect('corr.type', 'type')
.leftJoinAndSelect('corr.project', 'project')
.leftJoinAndSelect('corr.originator', 'org')
.leftJoinAndSelect('rev.status', 'status');
// Filter by Revision Status
const revStatus = searchDto.revisionStatus || 'CURRENT';
if (revStatus === 'CURRENT') {
query.where('rev.isCurrent = :isCurrent', { isCurrent: true });
} else if (revStatus === 'OLD') {
query.where('rev.isCurrent = :isCurrent', { isCurrent: false });
}
// If 'ALL', no filter needed on isCurrent
if (projectId) {
query.andWhere('corr.projectId = :projectId', { projectId });
}
if (typeId) {
query.andWhere('corr.correspondenceTypeId = :typeId', { typeId });
}
if (statusId) {
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)',
{ search: `%${search}%` }
);
}
// Default Sort: Latest Created
query.orderBy('rev.createdAt', 'DESC').skip(skip).take(limit);
const [items, total] = await query.getManyAndCount();
return {
data: items,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
async findOne(id: number) {
const correspondence = await this.correspondenceRepo.findOne({
where: { id },
relations: [
'revisions',
'revisions.status',
'type',
'project',
'originator',
'recipients',
'recipients.recipientOrganization', // [v1.5.1] Fixed relation name
'discipline',
'discipline.contract',
],
});
if (!correspondence) {
throw new NotFoundException('Correspondence', String(id));
}
return correspondence;
}
async findOneByUuid(publicId: string) {
const correspondence = await this.correspondenceRepo
.createQueryBuilder('corr')
.leftJoinAndSelect('corr.revisions', 'rev')
.leftJoinAndSelect('rev.status', 'status')
.leftJoinAndSelect('rev.attachmentLinks', 'revAttachmentLink')
.leftJoinAndSelect('revAttachmentLink.attachment', 'attachment')
.leftJoinAndSelect('corr.type', 'type')
.leftJoinAndSelect('corr.project', 'project')
.leftJoinAndSelect('corr.originator', 'originator')
.leftJoinAndSelect('corr.recipients', 'recipient')
.leftJoinAndSelect(
'recipient.recipientOrganization',
'recipientOrganization'
)
.leftJoinAndSelect('corr.discipline', 'discipline')
.leftJoinAndSelect('discipline.contract', 'contract')
.where('corr.publicId = :publicId', { publicId })
.orderBy('rev.revisionNumber', 'DESC')
.addOrderBy('rev.createdAt', 'DESC')
.getOne();
if (!correspondence) {
throw new NotFoundException('Correspondence', publicId);
}
// ADR-021: expose live workflow state (null-safe — Draft \u0e22\u0e31\u0e07\u0e44\u0e21\u0e48\u0e21\u0e35 workflow instance)
const workflowInstance = await this.workflowEngine.getInstanceByEntity(
'correspondence',
correspondence.publicId
);
return {
...correspondence,
workflowInstanceId: workflowInstance?.id ?? null,
workflowState: workflowInstance?.currentState ?? null,
availableActions: workflowInstance?.availableActions ?? [],
};
}
async addReference(id: number, dto: AddReferenceDto) {
const source = await this.correspondenceRepo.findOne({ where: { id } });
// ADR-019: Resolve target publicId → internal INT id
const target = await this.correspondenceRepo.findOne({
where: { publicId: dto.targetUuid },
});
if (!source || !target) {
throw new NotFoundException('Source or Target correspondence');
}
if (source.id === target.id) {
throw new BusinessException(
'SELF_REFERENCE',
'Cannot reference self',
'ไม่สามารถอ้างอิงเอกสารเดียวกันได้'
);
}
const exists = await this.referenceRepo.findOne({
where: {
sourceId: id,
targetId: target.id,
},
});
if (exists) {
return exists;
}
const ref = this.referenceRepo.create({
sourceId: id,
targetId: target.id,
});
return this.referenceRepo.save(ref);
}
async removeReference(id: number, targetId: number) {
const result = await this.referenceRepo.delete({
sourceId: id,
targetId: targetId,
});
if (result.affected === 0) {
throw new NotFoundException('Reference');
}
}
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', String(id));
}
const tag = await this.dataSource.manager.findOne(Tag, {
where: { id: tagId },
});
if (!tag) {
throw new NotFoundException('Tag', String(tagId));
}
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');
}
}
async getReferences(id: number) {
const outgoing = await this.referenceRepo.find({
where: { sourceId: id },
relations: ['target', 'target.type'],
});
const incoming = await this.referenceRepo.find({
where: { targetId: id },
relations: ['source', 'source.type'],
});
return { outgoing, incoming };
}
async update(id: number, updateDto: UpdateCorrespondenceDto, user: User) {
// 1. Find Current Revision
const revision = await this.revisionRepo.findOne({
where: {
correspondenceId: id,
isCurrent: true,
},
relations: ['correspondence'],
});
if (!revision) {
throw new NotFoundException('Current revision', `correspondence:${id}`);
}
// 2. Check Permission
if (revision.statusId) {
const status = await this.statusRepo.findOne({
where: { id: revision.statusId },
});
if (status && status.statusCode !== 'DRAFT') {
const permissions = await this.userService.getUserPermissions(
user.user_id
);
const canEditSubmittedOrLater =
permissions.includes('correspondence.cancel') ||
permissions.includes('system.manage_all');
if (!canEditSubmittedOrLater) {
throw new PermissionException('correspondence', 'edit non-draft');
}
}
}
// ADR-019: Resolve UUID references in update DTO
const updResolvedProjectId = updateDto.projectId
? await this.uuidResolver.resolveProjectId(updateDto.projectId)
: undefined;
const updResolvedOriginatorId = updateDto.originatorId
? await this.uuidResolver.resolveOrganizationId(updateDto.originatorId)
: undefined;
const updResolvedRecipients = updateDto.recipients
? await Promise.all(
updateDto.recipients.map(
async (r): Promise<ResolvedRecipient> => ({
organizationId: await this.uuidResolver.resolveOrganizationId(
r.organizationId
),
type: r.type,
})
)
)
: undefined;
// 3. Check if number regeneration is needed (only for DRAFT status)
const oldCorr = revision.correspondence;
if (!oldCorr) {
throw new SystemException(
'Correspondence relation not loaded for revision'
);
}
const oldRecipientOrgId = oldCorr.recipients?.find(
(r) => r.recipientType === 'TO'
)?.recipientOrganizationId;
const newRecipientOrgId = updResolvedRecipients?.find(
(r) => r.type === 'TO'
)?.organizationId;
const needsNumberRegen =
(updResolvedProjectId && updResolvedProjectId !== oldCorr.projectId) ||
(updateDto.typeId && updateDto.typeId !== oldCorr.correspondenceTypeId) ||
(newRecipientOrgId && newRecipientOrgId !== oldRecipientOrgId);
let newNumber: string | undefined;
if (needsNumberRegen) {
// Check if current status is DRAFT - only regenerate for drafts
const currentStatus = await this.statusRepo.findOne({
where: { id: revision.statusId },
});
if (currentStatus?.statusCode === 'DRAFT') {
// Resolve originator for number generation
const originatorId =
updResolvedOriginatorId ||
oldCorr.originatorId ||
user.primaryOrganizationId;
// Get type info for number generation
const typeId = updateDto.typeId || oldCorr.correspondenceTypeId;
const type = await this.typeRepo.findOne({ where: { id: typeId } });
if (!type) {
throw new NotFoundException('Document Type', String(typeId));
}
// Get recipient org code for number generation
const recipientOrgId = newRecipientOrgId || oldRecipientOrgId;
let _recipientCode = '';
if (recipientOrgId) {
const recOrg = await this.dataSource.manager.findOne(Organization, {
where: { id: recipientOrgId },
});
if (recOrg) _recipientCode = recOrg.organizationCode;
}
const projectId = updResolvedProjectId || oldCorr.projectId;
newNumber = await this.numberingService.updateNumberForDraft(
oldCorr.correspondenceNumber,
{
projectId: oldCorr.projectId,
originatorOrganizationId:
oldCorr.originatorId || user.primaryOrganizationId || 0,
typeId: oldCorr.correspondenceTypeId,
disciplineId: oldCorr.disciplineId,
recipientOrganizationId: oldRecipientOrgId || 0,
userId: user.user_id,
},
{
projectId,
originatorOrganizationId:
originatorId || user.primaryOrganizationId || 0,
typeId,
disciplineId: updateDto.disciplineId || oldCorr.disciplineId,
recipientOrganizationId: recipientOrgId || 0,
userId: user.user_id,
}
);
}
}
// 4. Update Correspondence Entity if needed
const correspondenceUpdate: Record<string, unknown> = {};
if (newNumber) correspondenceUpdate.correspondenceNumber = newNumber;
if (updateDto.disciplineId)
correspondenceUpdate.disciplineId = updateDto.disciplineId;
if (updResolvedProjectId)
correspondenceUpdate.projectId = updResolvedProjectId;
if (updResolvedOriginatorId)
correspondenceUpdate.originatorId = updResolvedOriginatorId;
if (updateDto.typeId)
correspondenceUpdate.correspondenceTypeId = updateDto.typeId;
if (Object.keys(correspondenceUpdate).length > 0) {
await this.correspondenceRepo.update(id, correspondenceUpdate);
}
// 4. Update Revision Entity
const revisionUpdate: Record<string, unknown> = {};
if (updateDto.subject) revisionUpdate.subject = updateDto.subject;
if (updateDto.body) revisionUpdate.body = updateDto.body;
if (updateDto.remarks) revisionUpdate.remarks = updateDto.remarks;
// Format Date correctly if string
if (updateDto.dueDate) revisionUpdate.dueDate = new Date(updateDto.dueDate);
if (updateDto.documentDate)
revisionUpdate.documentDate = new Date(updateDto.documentDate);
if (updateDto.issuedDate)
revisionUpdate.issuedDate = new Date(updateDto.issuedDate);
if (updateDto.receivedDate)
revisionUpdate.receivedDate = new Date(updateDto.receivedDate);
if (updateDto.description)
revisionUpdate.description = updateDto.description;
if (updateDto.details) revisionUpdate.details = updateDto.details;
if (Object.keys(revisionUpdate).length > 0) {
await this.revisionRepo.update(revision.id, revisionUpdate);
}
// 4.5 Commit new attachments from Temp → Permanent (Two-Phase Storage)
if (updateDto.attachmentTempIds?.length) {
const issueDate = updateDto.issuedDate
? new Date(updateDto.issuedDate)
: updateDto.documentDate
? new Date(updateDto.documentDate)
: revision.issuedDate || revision.documentDate || undefined;
// [FIX v1.8.1] commit ได้ Attachment records กลับมา → บันทึก junction
const committed = await this.fileStorageService.commit(
updateDto.attachmentTempIds,
{
issueDate: issueDate ? new Date(issueDate) : undefined,
documentType: 'Correspondence',
}
);
if (committed.length > 0) {
const links = committed.map((att) =>
this.revAttachRepo.create({
correspondenceRevisionId: revision.id,
attachmentId: att.id,
isMainDocument: false, // ไฟล์ที่ upload เพิ่มเติมไม่ใช่ main
})
);
await this.revAttachRepo.save(links);
}
}
// 5. Update Recipients if provided
if (updResolvedRecipients) {
const recipientRepo = this.dataSource.getRepository(
CorrespondenceRecipient
);
await recipientRepo.delete({ correspondenceId: id });
const newRecipients = updResolvedRecipients.map((r) =>
recipientRepo.create({
correspondenceId: id,
recipientOrganizationId: r.organizationId,
recipientType: r.type,
})
);
await recipientRepo.save(newRecipients);
}
const updated = await this.findOne(id);
// Re-index updated document in Elasticsearch (fire-and-forget)
void this.searchService.indexDocument({
id: updated.id,
publicId: updated.publicId,
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) {
// ADR-019: Resolve UUID references
const previewProjectId = await this.uuidResolver.resolveProjectId(
createDto.projectId
);
const previewOriginatorId = createDto.originatorId
? await this.uuidResolver.resolveOrganizationId(createDto.originatorId)
: undefined;
const previewRecipients = createDto.recipients
? await Promise.all(
createDto.recipients.map(
async (r): Promise<ResolvedRecipient> => ({
organizationId: await this.uuidResolver.resolveOrganizationId(
r.organizationId
),
type: r.type,
})
)
)
: undefined;
const type = await this.typeRepo.findOne({
where: { id: createDto.typeId },
});
if (!type)
throw new NotFoundException('Document Type', String(createDto.typeId));
let userOrgId = user.primaryOrganizationId;
if (!userOrgId) {
const fullUser = await this.userService.findOne(user.user_id);
if (fullUser) userOrgId = fullUser.primaryOrganizationId;
}
if (previewOriginatorId && previewOriginatorId !== userOrgId) {
// Allow impersonation for preview
userOrgId = previewOriginatorId;
}
// Extract recipient from recipients array
const toRecipient = previewRecipients?.find((r) => r.type === 'TO');
const recipientOrganizationId = toRecipient?.organizationId;
let recipientCode = '';
if (recipientOrganizationId) {
const recOrg = await this.dataSource.manager.findOne(Organization, {
where: { id: recipientOrganizationId },
});
if (recOrg) recipientCode = recOrg.organizationCode;
}
return this.numberingService.previewNumber({
projectId: previewProjectId,
originatorOrganizationId: userOrgId!,
typeId: createDto.typeId,
disciplineId: createDto.disciplineId,
subTypeId: createDto.subTypeId,
recipientOrganizationId,
year: new Date().getFullYear(),
customTokens: {
TYPE_CODE: type.typeCode,
RECIPIENT_CODE: recipientCode,
REC_CODE: recipientCode,
},
});
}
/**
* Business Rule Implementation: EC-CORR-001 - Cancel Correspondence with Downstream Circulation
* Cancel correspondence and handle related circulations
*/
async cancel(publicId: string, reason: string, user: User) {
const correspondence = await this.findOneByUuid(publicId);
// 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 PermissionException('correspondence', 'cancel');
}
// Check if there are any active circulations
const circulationRepo = this.dataSource.getRepository(Circulation);
const activeCirculations = await circulationRepo.find({
where: {
correspondenceId: correspondence.id,
statusCode: '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');
}
// Get cancelled status
const cancelledStatus = await this.statusRepo.findOne({
where: { statusCode: 'CANCELLED' },
});
if (!cancelledStatus) {
throw new SystemException('CANCELLED status not found in Master Data');
}
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}`,
}
);
await queryRunner.commitTransaction();
// Force close all active circulations
if (activeCirculations.length > 0) {
for (const circ of activeCirculations) {
try {
await this.circulationService.forceClose(
circ.publicId,
`Correspondence cancelled: ${reason}`,
user
);
// T012: Enqueue BullMQ notification for affected assignees
// CirculationService.forceClose already updates status, we just need to notify.
// Ideally we'd notify the people who were pending.
const circWithRoutings = await this.dataSource
.getRepository(CirculationRouting)
.find({
where: { circulationId: circ.id, status: 'REJECTED' },
});
for (const r of circWithRoutings) {
if (r.assignedTo) {
void this.notificationService.send({
userId: r.assignedTo,
title: 'Circulation Force Closed',
message: `ใบเวียน ${circ.circulationNo} ถูกปิดแบบบังคับ เนื่องจากเอกสารต้นทางถูกยกเลิก`,
type: 'EMAIL',
entityType: 'circulation',
entityId: circ.id,
link: `/circulations/${circ.publicId}`,
});
}
}
} catch (e) {
this.logger.error(
`Failed to force close circulation ${circ.publicId}: ${(e as Error).message}`
);
}
}
}
// Re-index cancelled status in Elasticsearch (fire-and-forget)
void this.searchService.indexDocument({
id: correspondence.id,
publicId: correspondence.publicId,
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.publicId}`,
});
}
})
.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(
publicIds: string[],
reason: string,
user: User
): Promise<{ succeeded: string[]; failed: string[] }> {
const succeeded: string[] = [];
const failed: string[] = [];
for (const publicId of publicIds) {
try {
await this.cancel(publicId, reason, user);
succeeded.push(publicId);
} catch {
failed.push(publicId);
}
}
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;
}
}