// File: src/modules/correspondence/correspondence.service.ts import { Injectable, NotFoundException, BadRequestException, InternalServerErrorException, ForbiddenException, Logger, } from '@nestjs/common'; 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 { User } from '../user/entities/user.entity'; import { Organization } from '../organization/entities/organization.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'; import { DeepPartial } from 'typeorm'; // 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 { Project } from '../project/entities/project.entity'; /** * CorrespondenceService - Document management (CRUD) * * NOTE: Workflow operations (submit, processAction) have been moved to * CorrespondenceWorkflowService which uses the Unified Workflow Engine. */ @Injectable() export class CorrespondenceService { private readonly logger = new Logger(CorrespondenceService.name); constructor( @InjectRepository(Correspondence) private correspondenceRepo: Repository, @InjectRepository(CorrespondenceRevision) private revisionRepo: Repository, @InjectRepository(CorrespondenceType) private typeRepo: Repository, @InjectRepository(CorrespondenceStatus) private statusRepo: Repository, @InjectRepository(CorrespondenceReference) private referenceRepo: Repository, @InjectRepository(Organization) private orgRepo: Repository, private numberingService: DocumentNumberingService, private jsonSchemaService: JsonSchemaService, private workflowEngine: WorkflowEngineService, private userService: UserService, private dataSource: DataSource, private searchService: SearchService, private fileStorageService: FileStorageService ) {} /** * ADR-019: Resolve projectId (INT or UUID string) to internal INT ID */ private async resolveProjectId(projectId: number | string): Promise { if (typeof projectId === 'number') return projectId; const num = Number(projectId); if (!isNaN(num)) return num; const project = await this.dataSource.manager.findOne(Project, { where: { uuid: projectId }, select: ['id'], }); if (!project) throw new NotFoundException(`Project with UUID ${projectId} not found`); return project.id; } /** * ADR-019: Resolve organizationId (INT or UUID string) to internal INT ID */ private async resolveOrganizationId(orgId: number | string): Promise { if (typeof orgId === 'number') return orgId; const num = Number(orgId); if (!isNaN(num)) return num; const org = await this.orgRepo.findOne({ where: { uuid: orgId }, select: ['id'], }); if (!org) throw new NotFoundException(`Organization with UUID ${orgId} not found`); return org.id; } async create(createDto: CreateCorrespondenceDto, user: User) { // ADR-019: Resolve UUID references to internal INT IDs const resolvedProjectId = await this.resolveProjectId(createDto.projectId); const resolvedOriginatorId = createDto.originatorId ? await this.resolveOrganizationId(createDto.originatorId) : undefined; const resolvedRecipients = createDto.recipients ? await Promise.all( createDto.recipients.map(async (r) => ({ organizationId: await this.resolveOrganizationId(r.organizationId), type: r.type, })) ) : undefined; const type = await this.typeRepo.findOne({ where: { id: createDto.typeId }, }); if (!type) throw new NotFoundException('Document Type not found'); const statusDraft = await this.statusRepo.findOne({ where: { statusCode: 'DRAFT' }, }); if (!statusDraft) { throw new InternalServerErrorException( '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 permissions = await this.userService.getUserPermissions( user.user_id ); if (!permissions.includes('system.manage_all')) { throw new ForbiddenException( 'You do not have permission to create documents on behalf of other organizations.' ); } userOrgId = resolvedOriginatorId; } if (!userOrgId) { throw new BadRequestException( '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.orgRepo.findOne({ 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.orgRepo.findOne({ 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: 'A', 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, 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; await this.fileStorageService.commit(createDto.attachmentTempIds, { issueDate, documentType: 'Correspondence', }); } await queryRunner.commitTransaction(); // Start Workflow Instance (non-blocking) try { const workflowCode = `CORRESPONDENCE_${type.typeCode}`; await this.workflowEngine.createInstance( workflowCode, 'correspondence', savedCorr.id.toString(), { projectId: resolvedProjectId, originatorId: userOrgId, disciplineId: createDto.disciplineId, initiatorId: user.user_id, } ); } catch (error) { this.logger.warn( `Workflow not started for ${docNumber.number} (Code: CORRESPONDENCE_${type.typeCode}): ${(error as Error).message}` ); } // Fire-and-forget search indexing (non-blocking, void intentional) void this.searchService.indexDocument({ id: savedCorr.id, 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, 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 (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 ], }); if (!correspondence) { throw new NotFoundException(`Correspondence with ID ${id} not found`); } return correspondence; } async findOneByUuid(uuid: string) { const correspondence = await this.correspondenceRepo.findOne({ where: { uuid }, relations: [ 'revisions', 'revisions.status', 'type', 'project', 'originator', 'recipients', 'recipients.recipientOrganization', ], }); if (!correspondence) { throw new NotFoundException(`Correspondence with UUID ${uuid} not found`); } return correspondence; } async addReference(id: number, dto: AddReferenceDto) { const source = await this.correspondenceRepo.findOne({ where: { id } }); // ADR-019: Resolve target UUID → internal INT id const target = await this.correspondenceRepo.findOne({ where: { uuid: dto.targetUuid }, }); if (!source || !target) { throw new NotFoundException('Source or Target correspondence not found'); } if (source.id === target.id) { throw new BadRequestException('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 not found'); } } 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 for correspondence ${id} not found` ); } // 2. Check Permission if (revision.statusId) { const status = await this.statusRepo.findOne({ where: { id: revision.statusId }, }); if (status && status.statusCode !== 'DRAFT') { throw new BadRequestException('Only DRAFT documents can be updated'); } } // ADR-019: Resolve UUID references in update DTO const updResolvedProjectId = updateDto.projectId ? await this.resolveProjectId(updateDto.projectId) : undefined; const updResolvedOriginatorId = updateDto.originatorId ? await this.resolveOrganizationId(updateDto.originatorId) : undefined; const updResolvedRecipients = updateDto.recipients ? await Promise.all( updateDto.recipients.map(async (r) => ({ organizationId: await this.resolveOrganizationId(r.organizationId), type: r.type, })) ) : undefined; // 3. Update Correspondence Entity if needed const correspondenceUpdate: DeepPartial = {}; if (updateDto.disciplineId) correspondenceUpdate.disciplineId = updateDto.disciplineId; if (updResolvedProjectId) correspondenceUpdate.projectId = updResolvedProjectId; if (updResolvedOriginatorId) correspondenceUpdate.originatorId = updResolvedOriginatorId; if (Object.keys(correspondenceUpdate).length > 0) { await this.correspondenceRepo.update(id, correspondenceUpdate); } // 4. Update Revision Entity const revisionUpdate: DeepPartial = {}; 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.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; await this.fileStorageService.commit(updateDto.attachmentTempIds, { issueDate: issueDate ? new Date(issueDate) : undefined, documentType: 'Correspondence', }); } // 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); } // 6. Regenerate Document Number if structural fields changed (Recipient, Discipline, Type, Project) // AND it is a DRAFT. // Fetch fresh data for context and comparison const currentCorr = await this.correspondenceRepo.findOne({ where: { id }, relations: ['type', 'recipients', 'recipients.recipientOrganization'], }); if (currentCorr) { const currentToRecipient = currentCorr.recipients?.find( (r) => r.recipientType === 'TO' ); const currentRecipientId = currentToRecipient?.recipientOrganizationId; // Check for ACTUAL value changes const isProjectChanged = updResolvedProjectId !== undefined && updResolvedProjectId !== currentCorr.projectId; const isOriginatorChanged = updResolvedOriginatorId !== undefined && updResolvedOriginatorId !== currentCorr.originatorId; const isDisciplineChanged = updateDto.disciplineId !== undefined && updateDto.disciplineId !== currentCorr.disciplineId; const isTypeChanged = updateDto.typeId !== undefined && updateDto.typeId !== currentCorr.correspondenceTypeId; let isRecipientChanged = false; let newRecipientId: number | undefined; if (updResolvedRecipients) { const newToRecipient = updResolvedRecipients.find( (r) => r.type === 'TO' ); newRecipientId = newToRecipient?.organizationId; if (newRecipientId !== currentRecipientId) { isRecipientChanged = true; } } if ( isProjectChanged || isDisciplineChanged || isTypeChanged || isRecipientChanged || isOriginatorChanged ) { const targetRecipientId = isRecipientChanged ? newRecipientId : currentRecipientId; // Resolve Recipient Code for the NEW context let recipientCode = ''; if (targetRecipientId) { const recOrg = await this.orgRepo.findOne({ where: { id: targetRecipientId }, }); if (recOrg) recipientCode = recOrg.organizationCode; } // [Fix #6] Fetch real ORG Code from originator organization const originatorOrgForUpdate = await this.orgRepo.findOne({ where: { id: updResolvedOriginatorId ?? currentCorr.originatorId ?? 0, }, }); const orgCode = originatorOrgForUpdate?.organizationCode ?? 'UNK'; // Prepare Contexts const oldCtx = { projectId: currentCorr.projectId, originatorOrganizationId: currentCorr.originatorId ?? 0, typeId: currentCorr.correspondenceTypeId, disciplineId: currentCorr.disciplineId, recipientOrganizationId: currentRecipientId, year: new Date().getFullYear(), }; const newCtx = { projectId: updResolvedProjectId ?? currentCorr.projectId, originatorOrganizationId: updResolvedOriginatorId ?? currentCorr.originatorId ?? 0, typeId: updateDto.typeId ?? currentCorr.correspondenceTypeId, disciplineId: updateDto.disciplineId ?? currentCorr.disciplineId, recipientOrganizationId: targetRecipientId, year: new Date().getFullYear(), userId: user.user_id, // Pass User ID for Audit customTokens: { TYPE_CODE: currentCorr.type?.typeCode || '', ORG_CODE: orgCode, RECIPIENT_CODE: recipientCode, REC_CODE: recipientCode, }, }; // If Type Changed, need NEW Type Code if (isTypeChanged) { const newType = await this.typeRepo.findOne({ where: { id: newCtx.typeId }, }); if (newType) newCtx.customTokens.TYPE_CODE = newType.typeCode; } const newDocNumber = await this.numberingService.updateNumberForDraft( currentCorr.correspondenceNumber, oldCtx, newCtx ); await this.correspondenceRepo.update(id, { correspondenceNumber: newDocNumber, }); } } return this.findOne(id); } async previewDocumentNumber(createDto: CreateCorrespondenceDto, user: User) { // ADR-019: Resolve UUID references const previewProjectId = await this.resolveProjectId(createDto.projectId); const previewOriginatorId = createDto.originatorId ? await this.resolveOrganizationId(createDto.originatorId) : undefined; const previewRecipients = createDto.recipients ? await Promise.all( createDto.recipients.map(async (r) => ({ organizationId: await this.resolveOrganizationId(r.organizationId), type: r.type, })) ) : undefined; const type = await this.typeRepo.findOne({ where: { id: createDto.typeId }, }); if (!type) throw new NotFoundException('Document Type not found'); 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.orgRepo.findOne({ 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, }, }); } }