using System.Text.Json; using ECPBLL.Correspondence.Interfaces; using ECPDAL.Correspondence.DTOs; using ECPDAL.Correspondence.Interfaces; using GB5Shared.ActionProcessor; using GB5Shared.DTO.ECM; using GB5Shared.FileUpload; using GB5Shared.DaprCache; using GB5Shared.DocumentMerge; using GB5Shared.DTO.Framework.Criteria; using GB5Shared.DTO.Framework.Login; using GB5Shared.EventLogPublish; using GB5Shared.GB5Constant; using GB5Shared.GenerateAutoNumber; using GB5Shared.Resource.Response; using GB5Shared.Telemetry; using GB5Shared.Validation; using Microsoft.Extensions.Logging; namespace ECPBLL.Correspondence.Implementations; public class CorrespondenceBLL( ICorrespondenceDAL _dal, IDocumentMergeService _mergeService, IFileUploadBLL _fileUploadBLL, IEventActionRunDAL _runDal, IActionOutboxDAL _outboxDal, IValidation _validation, AutoNumber _autoNumber, KeyInvalidate _keyInvalidate, EventLogPublish _eventLog, ILogger _logger ) : ICorrespondenceBLL { // PropertyNamingPolicy left at its default (preserve declared PascalCase names) -- see // Communication/CommunicationBLL/Implementations/CommentThreadBLL.cs for why this override // was removed. private static readonly JsonSerializerOptions JsonOpts = new(); // ── GET ─────────────────────────────────────────────────────────── public async Task GetCorrespondenceAsync(int correspondenceId, LoginDTO login, CancellationToken ct) { GB5Trace.Step("get-correspondence", new { correspondenceId }); var entity = await _dal.GetCorrespondenceAsync(correspondenceId, login, ct).ConfigureAwait(false); if (entity is null) throw new InvalidOperationException($"Correspondence {correspondenceId} not found."); return JsonSerializer.Serialize(entity, JsonOpts); } public async Task GetCorrespondenceHistoryAsync(int objectTypeId, int objectId, LoginDTO login, CancellationToken ct) { GB5Trace.Step("get-correspondence-history", new { objectTypeId, objectId }); var list = await _dal.GetCorrespondenceHistoryAsync(objectTypeId, objectId, login, ct).ConfigureAwait(false); return JsonSerializer.Serialize(list, JsonOpts); } public async Task<(string Json, int Total)> GetCorrespondenceListAsync( CriteriaDTO? criteriaDTO, int pageNumber, int pageSize, LoginDTO login, CancellationToken ct) { GB5Trace.Step("get-correspondence-list", new { criteriaDTO?.SectionCriteriaList?.Count, pageNumber, pageSize }); var paged = await _dal.GetCorrespondenceListAsync(criteriaDTO, pageNumber, pageSize, login, ct).ConfigureAwait(false); return (JsonSerializer.Serialize(paged.Items, JsonOpts), paged.TotalCount); } // ── PREVIEW (render only — no persistence, no send) ──────────────── public async Task GetCorrespondencePreviewAsync( CorrespondenceRenderRequestDTO dto, LoginDTO login, CancellationToken ct) { GB5Trace.Step("get-correspondence-preview", new { dto.ObjectTypeId, dto.ObjectId, dto.TemplateId }); if (dto.TemplateId <= 0) throw new InvalidOperationException("TemplateId is required."); try { var (templateBytes, templateFileName) = await LoadTemplateBytesAsync(dto.TemplateId, login).ConfigureAwait(false); var tables = ToReadOnlyTables(dto.Tables); var merged = await _mergeService .MergeAsync(templateBytes, templateFileName, dto.Fields!, tables, ct) .ConfigureAwait(false); return JsonSerializer.Serialize(new { merged.ContentType, merged.FileName, Base64Content = Convert.ToBase64String(merged.Content) }, JsonOpts); } catch (Exception ex) { GB5Trace.MarkFailed("get-correspondence-preview-failed", ex); _logger.LogError(ex, "GetCorrespondencePreview failed for TemplateId {TemplateId}", dto.TemplateId); throw; } } // ── SAVE AND SEND ─────────────────────────────────────────────────── public async Task SaveAndSendCorrespondenceAsync( SaveAndSendCorrespondenceDTO dto, LoginDTO login, CancellationToken ct) { GB5Trace.Step("validate-correspondence", new { dto.ObjectTypeId, dto.ObjectId, dto.Mode }); if (dto.ObjectTypeId == 0) throw new InvalidOperationException("ObjectTypeId is required."); if (dto.ObjectId == 0) throw new InvalidOperationException("ObjectId is required."); if (dto.TemplateId <= 0) throw new InvalidOperationException("TemplateId is required."); if (dto.Mode is not (CorrespondenceConstants.Mode.Mail or CorrespondenceConstants.Mode.Sms)) throw new InvalidOperationException("Mode must be 1 (mail) or 2 (sms)."); await _validation.NotEmpty(dto.ToIds ?? string.Empty, nameof(dto.ToIds)).ConfigureAwait(false); try { // 1. Merge the document. var (templateBytes, templateFileName) = await LoadTemplateBytesAsync(dto.TemplateId, login).ConfigureAwait(false); var tables = ToReadOnlyTables(dto.Tables); GB5Trace.Step("merge-correspondence-document", new { dto.TemplateId }); var merged = await _mergeService .MergeAsync(templateBytes, templateFileName, dto.Fields!, tables, ct) .ConfigureAwait(false); // 2. Upload the merged document via the existing TATTACHMENT/ECM pattern. GB5Trace.Step("upload-correspondence-attachment", new { dto.ObjectTypeId, dto.ObjectId }); var uploadResult = await _fileUploadBLL.FileUploadBase64Async(new FileUploadBase64RequestDTO { FileName = merged.FileName, DisplayFileName = merged.FileName, Base64Content = Convert.ToBase64String(merged.Content), ObjectId = dto.ObjectId, ObjectTypeId = CorrespondenceConstants.CorrespondenceEntityId, Remarks = "Correspondence generated document", }, login).ConfigureAwait(false); var attachmentId = uploadResult.AttachmentId; // 3. Persist TCORRESPONDENCE. var autoNumber = await _autoNumber.GetNumberAsync(1, "CORRESPONDENCE", login).ConfigureAwait(false); var newId = autoNumber.StartNumber; var entity = new CorrespondenceDTO { CorrespondenceId = newId, ObjectTypeId = dto.ObjectTypeId, ObjectId = dto.ObjectId, CorrespondenceTypeId = dto.CorrespondenceTypeId, BizTransactionTypeId = dto.BizTransactionTypeId, Direction = CorrespondenceConstants.Direction.Out, Mode = dto.Mode, Subject = dto.Subject, TemplateId = dto.TemplateId, GeneratedAttachmentId = attachmentId, FromId = dto.FromId, ToIds = dto.ToIds, CcIds = dto.CcIds, BccIds = dto.BccIds, ScheduledOn = null, SentOn = null, ActionStatus = CorrespondenceConstants.ActionStatus.Pending, BizTransactionId = dto.BizTransactionId, OuId = dto.OuId, TenantId = login.ClientId, DatabaseName = login.DatabaseName, DatabaseType = login.DatabaseType, Version = 0, Status = CorrespondenceConstants.RowStatus.Active, CreatedById = login.UserId, CreatedOn = DateTime.UtcNow, ModifiedById = login.UserId, ModifiedOn = DateTime.UtcNow, }; GB5Trace.Step("save-correspondence", new { newId }); await _dal.SaveCorrespondenceAsync(entity, login, ct).ConfigureAwait(false); GB5Trace.Step("event-publish", new { Constant.EventTypeConstant.CORRESPONDENCECREATEDEVENTTYPEID }); await _eventLog.PublishEventLogAsync( "Correspondence Created", entity, Constant.EventTypeConstant.CORRESPONDENCECREATEDEVENTTYPEID, newId, login, ct: ct) .ConfigureAwait(false); // 4. Queue TEVENTACTIONRUN + TACTIONOUTBOX directly — this is an explicit // user-triggered send, not an automatic MACTION rule, so EventSubBLL's rule // evaluation is intentionally bypassed (see plan's Phase 2 trigger decision). await QueueDeliveryAsync(entity, login, ct).ConfigureAwait(false); await InvalidateHistoryCacheAsync(dto.ObjectTypeId, dto.ObjectId, login).ConfigureAwait(false); return $"{SuccessResponse.SaveSuccessMessage} {newId}"; } catch (Exception ex) { GB5Trace.MarkFailed("save-and-send-correspondence-failed", ex); _logger.LogError(ex, "SaveAndSendCorrespondence failed for ObjectType {ObjectTypeId} Object {ObjectId}", dto.ObjectTypeId, dto.ObjectId); throw; } } // ── UPDATE ──────────────────────────────────────────────────────── public async Task UpdateCorrespondenceAsync( UpdateCorrespondenceDTO dto, LoginDTO login, CancellationToken ct) { GB5Trace.Step("validate-update-correspondence", new { dto.CorrespondenceId }); if (dto.CorrespondenceId <= 0) throw new InvalidOperationException("CorrespondenceId is required."); if (dto.Mode is not (CorrespondenceConstants.Mode.Mail or CorrespondenceConstants.Mode.Sms)) throw new InvalidOperationException("Mode must be 1 (mail) or 2 (sms)."); await _validation.NotEmpty(dto.ToIds ?? string.Empty, nameof(dto.ToIds)).ConfigureAwait(false); var existing = await _dal.GetCorrespondenceAsync(dto.CorrespondenceId, login, ct).ConfigureAwait(false); if (existing is null) throw new InvalidOperationException($"Correspondence {dto.CorrespondenceId} not found."); try { GB5Trace.Step("update-correspondence", new { dto.CorrespondenceId, dto.Version }); var updated = await _dal.UpdateCorrespondenceAsync(dto, login.UserId, login, ct).ConfigureAwait(false); if (!updated) throw new InvalidOperationException( $"Correspondence {dto.CorrespondenceId} could not be updated — it may have been modified or deleted by another user."); await InvalidateHistoryCacheAsync(existing.ObjectTypeId, existing.ObjectId, login).ConfigureAwait(false); await InvalidateDetailCacheAsync(dto.CorrespondenceId, login).ConfigureAwait(false); return $"{SuccessResponse.UpdateSuccessMessage} {dto.CorrespondenceId}"; } catch (Exception ex) { GB5Trace.MarkFailed("update-correspondence-failed", ex); _logger.LogError(ex, "UpdateCorrespondence failed for CorrespondenceId {CorrespondenceId}", dto.CorrespondenceId); throw; } } // ── DELETE ──────────────────────────────────────────────────────── public async Task DeleteCorrespondenceAsync(int correspondenceId, LoginDTO login, CancellationToken ct) { if (correspondenceId <= 0) throw new InvalidOperationException("CorrespondenceId is required."); var existing = await _dal.GetCorrespondenceAsync(correspondenceId, login, ct).ConfigureAwait(false); if (existing is null) throw new InvalidOperationException($"Correspondence {correspondenceId} not found."); try { GB5Trace.Step("delete-correspondence", new { correspondenceId }); var deleted = await _dal.DeleteCorrespondenceAsync(correspondenceId, login.UserId, login, ct).ConfigureAwait(false); if (!deleted) throw new InvalidOperationException(ErrorResponse.DeleteNotFoundMessage); await InvalidateHistoryCacheAsync(existing.ObjectTypeId, existing.ObjectId, login).ConfigureAwait(false); await InvalidateDetailCacheAsync(correspondenceId, login).ConfigureAwait(false); return SuccessResponse.DeleteSuccessMessage; } catch (Exception ex) { GB5Trace.MarkFailed("delete-correspondence-failed", ex); _logger.LogError(ex, "DeleteCorrespondence failed for CorrespondenceId {CorrespondenceId}", correspondenceId); throw; } } // ── Helpers ─────────────────────────────────────────────────────── /// /// Loads template bytes for the merge engine. IMPORTANT DEVIATION from the plan's literal /// text: TemplateId is treated as a TATTACHMENT.ATTACHMENTID of a previously-uploaded /// .docx/.xlsx file, NOT a GB5Framework MergeDTO.MergeId. MergeBLL/MergeDTO (Merge/MergeDetail) /// is a DB-field-to-merge-field MAPPING definition with no file/attachment reference of any /// kind — confirmed during Phase 2 implementation, matching the plan's own "Open items" note /// that template upload/storage is unresolved. Reusing TATTACHMENT/ECM for template storage /// (the same pattern already used for the generated output document) avoids inventing new /// infrastructure for an explicitly-deferred design question. /// private async Task<(byte[] Bytes, string FileName)> LoadTemplateBytesAsync(int templateAttachmentId, LoginDTO login) { var attachment = await _fileUploadBLL.DownloadAttachmentAsBase64(templateAttachmentId, login).ConfigureAwait(false); return (Convert.FromBase64String(attachment.Base64Content), attachment.FileName); } private static IReadOnlyDictionary>> ToReadOnlyTables( Dictionary>> tables) { return tables.ToDictionary( kv => kv.Key, kv => (IReadOnlyList>) kv.Value.Select(r => (IReadOnlyDictionary)r).ToList()); } private async Task QueueDeliveryAsync(CorrespondenceDTO entity, LoginDTO login, CancellationToken ct) { var correlationKey = $"correspondence-{entity.CorrespondenceId}-{Guid.NewGuid():N}"; // ActionId = -1: this send is explicitly user-triggered (SaveAndSendCorrespondence), not a // rule evaluated by EventSubBLL against an MACTION row — there is no MACTION configuration // for a one-off manual send, so -1 follows the "-1 = not applicable" convention used // throughout GB5 (TenantId/BizTransactionId/etc. defaults). var actionRunId = await _runDal.InsertAsync(new EventActionRunDTO { ActionId = -1, JobExecutionId = -1, EventTypeId = Constant.EventTypeConstant.CORRESPONDENCECREATEDEVENTTYPEID, Payload = JsonSerializer.Serialize(entity, JsonOpts), CorrelationKey = correlationKey, }, login, ct).ConfigureAwait(false); var actionEventDto = new ActionEventDto { ActionRunId = actionRunId, ActionId = -1, ActionType = CorrespondenceConstants.CorrespondenceActionType, TenantId = login.ClientId, DatabaseName = login.DatabaseName, SendTo = entity.ToIds, ToDeliveryType = 3, // direct value(s) already resolved by the caller MailCc = entity.CcIds, MailCcDeliveryType = 3, MailBcc = entity.BccIds, MailBccDeliveryType = 3, TemplateId = -1, // subject/body travel in Payload below — no MMAILTEMPLATE lookup CorrelationKey = correlationKey, AttachmentId = entity.GeneratedAttachmentId > 0 ? entity.GeneratedAttachmentId : null, Payload = JsonSerializer.SerializeToElement(new { Subject = entity.Subject ?? string.Empty, Body = entity.Subject ?? string.Empty, IsHtml = false, CorrespondenceId = entity.CorrespondenceId, Mode = entity.Mode, }), }; var partition = Math.Abs(login.ClientId % 5); var destinationTopic = $"action-exec-p{partition}"; await _outboxDal.InsertAsync(new ActionOutboxDTO { ActionRunId = actionRunId, DestinationTopic = destinationTopic, Payload = JsonSerializer.Serialize(actionEventDto, JsonOpts), TenantId = login.ClientId, CorrelationKey = correlationKey, }, login, ct).ConfigureAwait(false); } // Clears GetCorrespondenceHistory's cache (keyed "{objectTypeId}:{objectId}") — needed // whenever a correspondence linked to this object is created/updated/deleted. Does NOT cover // GetCorrespondence's own per-id cache — see InvalidateDetailCacheAsync for that. private async Task InvalidateHistoryCacheAsync(int objectTypeId, int objectId, LoginDTO login) { var cacheKey = new CacheKeyGeneration().KeyGeneration( $"{objectTypeId}:{objectId}", Constant.EntityConstant.OBJECTCORRESPONDENCE, Constant.CacheKeyLevel.CLIENT_LEVEL, login); await _keyInvalidate.InvalidateCache(cacheKey).ConfigureAwait(false); } // Clears GetCorrespondence's own per-id cache (keyed by CorrespondenceId, same // OBJECTCORRESPONDENCE entity as the history key above but a different key string) — needed // by UpdateCorrespondence/DeleteCorrespondence, both of which mutate a specific existing row // directly (SaveAndSendCorrespondence never needs this: a brand-new row has no prior // single-item cache). private async Task InvalidateDetailCacheAsync(int correspondenceId, LoginDTO login) { var cacheKey = new CacheKeyGeneration().KeyGeneration( correspondenceId, Constant.EntityConstant.OBJECTCORRESPONDENCE, Constant.CacheKeyLevel.CLIENT_LEVEL, login); await _keyInvalidate.InvalidateCache(cacheKey).ConfigureAwait(false); } }