using GB5Shared.DaprCache; using GB5Shared.DTO.Framework.AutoNumber; using GB5Shared.DTO.Framework.Criteria; using GB5Shared.DTO.Framework.Login; using GB5Shared.GenerateAutoNumber; using GB5Shared.Resource.Response; using GB5Shared.Telemetry; using GB5Shared.Validation; using Newtonsoft.Json; using System.ComponentModel.DataAnnotations; using TMSDAL.CustomCode.Certificate; using TMSDAL.DTO.CertRenewalAlert; using TMSDAL.Enums; using static GB5Shared.GB5Constant.Constant; namespace TMSBLL.Certificate { public class TrainingCertificateBLL : ITrainingCertificateBLL { private readonly ITrainingCertificateDAL _CertificateDAL; private readonly ICertificatePdfPipeline _CertificatePdfPipeline; private readonly IValidation _Validation; private readonly AutoNumber _AutoNumber; private readonly KeyInvalidate _KeyInvalidate; public TrainingCertificateBLL(ITrainingCertificateDAL certificateDAL, ICertificatePdfPipeline certificatePdfPipeline, IValidation validation, AutoNumber AutoNumber, KeyInvalidate keyInvalidate) { _CertificateDAL = certificateDAL; _CertificatePdfPipeline = certificatePdfPipeline; _Validation = validation; _AutoNumber = AutoNumber; _KeyInvalidate = keyInvalidate; } // Non-fatal by design: the certificate ROW is already committed by the time this runs — // a rendering/signing/upload failure must not affect the SaveCertificate/RenewCertificate // response; it just leaves the certificate without a downloadable PDF until retried. private async Task IssuePdfNonFatalAsync(TrainingCertificateDTO dto, LoginDTO loginDTO) { try { await _CertificatePdfPipeline.IssueAsync(dto, loginDTO, default).ConfigureAwait(false); } catch (Exception ex) { GB5Trace.MarkFailed("certificate-pdf-issue-failed", ex); } } public async Task GetCertificate(int TrainingCertificateId, LoginDTO loginDTO) { try { var result = await _CertificateDAL.GetCertificate(TrainingCertificateId, loginDTO); return JsonConvert.SerializeObject(result); } catch (Exception) { throw; } } public async Task GetCertificateListByEmployee( LoginDTO loginDTO) { try { return await _CertificateDAL.GetCertificateListByEmployee( loginDTO); } catch (Exception) { throw; } } public async Task SaveCertificate(TrainingCertificateDTO dto, LoginDTO loginDTO) { if (dto == null) throw new ArgumentNullException(nameof(dto)); AutoNumberDTO? autoNumberDTO = null; try { dto.TenantId = loginDTO.ClientId; dto.ModifiedById = loginDTO.UserId; dto.ModifiedOn = DateTime.UtcNow; dto.SourceType = 5; if (dto.TrainingCertificateId == 0) { autoNumberDTO = await _AutoNumber.GetAutoNumber(1, "TRAININGCERTIFICATE", loginDTO); dto.TrainingCertificateId = autoNumberDTO.StartNumber; dto.CreatedById = loginDTO.UserId; dto.CreatedOn = DateTime.UtcNow; int result = await _CertificateDAL.SaveCertificate(dto, loginDTO); if (result > 0) { var cacheKey = new CacheKeyGeneration().KeyGeneration(dto.TrainingCertificateId, EntityConstant.OBJECTTRAININGCERTIFICATE, CacheKeyLevel.CLIENT_LEVEL, loginDTO); await _KeyInvalidate.AllInvalidateCache(cacheKey); await IssuePdfNonFatalAsync(dto, loginDTO); return string.Format(SuccessResponse.SaveSuccessWithCodeAndName, dto.CertificateNumber, dto.CertificateTitle); } return ErrorResponse.SaveErrorMessage; } else { int result = await _CertificateDAL.UpdateCertificate(dto, loginDTO); if (result > 0) { var cacheKey = new CacheKeyGeneration().KeyGeneration(dto.TrainingCertificateId, EntityConstant.OBJECTTRAININGCERTIFICATE, CacheKeyLevel.CLIENT_LEVEL, loginDTO); await _KeyInvalidate.AllInvalidateCache(cacheKey); return string.Format(SuccessResponse.UpdateSuccessWithCodeAndName, dto.CertificateNumber, dto.CertificateTitle); } return ErrorResponse.UpdateErrorMessage; } } catch (ValidationException vex) { throw new Exception(vex.Message); } catch (Exception) { if (autoNumberDTO != null && dto.TrainingCertificateId > 0) { await _AutoNumber.RollbackAutoNumber("TRAININGCERTIFICATE", dto.TrainingCertificateId, loginDTO); } throw; } } public async Task RenewCertificate(RenewCertificateDTO dto, LoginDTO loginDTO) { if (dto == null) throw new ArgumentNullException(nameof(dto)); if (dto.TrainingCertificateId == 0) throw new ValidationException("TrainingCertificateId is required."); if (string.IsNullOrWhiteSpace(dto.CertificateNumber)) throw new ValidationException("CertificateNumber is required."); if (dto.IssuedOn == default) throw new ValidationException("IssuedOn is required."); AutoNumberDTO? autoNumberDTO = null; try { var existing = await _CertificateDAL.GetCertificate(dto.TrainingCertificateId, loginDTO); if (existing == null) throw new Exception($"Certificate {dto.TrainingCertificateId} not found."); if (existing.CertStatus == (byte)CertStatus.Revoked) throw new Exception("A revoked certificate cannot be renewed."); autoNumberDTO = await _AutoNumber.GetAutoNumber(1, "TRAININGCERTIFICATE", loginDTO); var newCert = new TrainingCertificateDTO { TrainingCertificateId = autoNumberDTO.StartNumber, TrainingCompletionId = existing.TrainingCompletionId, EnrollmentId = existing.EnrollmentId, ProgrammeId = existing.ProgrammeId, EmployeeId = existing.EmployeeId, CertificateNumber = dto.CertificateNumber, CertificateTitle = dto.CertificateTitle ?? existing.CertificateTitle, IssuedOn = dto.IssuedOn, IssuedById = dto.IssuedById ?? existing.IssuedById, ExpiryDate = dto.ExpiryDate, RenewalWindowDays = dto.RenewalWindowDays ?? existing.RenewalWindowDays, CertificateUrl = dto.CertificateUrl, ExternalCertRef = dto.ExternalCertRef, CertStatus = (byte)CertStatus.Active, Version = existing.Version, Status = existing.Status, SortOrder = existing.SortOrder, TenantId = loginDTO.ClientId, SourceType = 5, CreatedById = loginDTO.UserId, CreatedOn = DateTime.UtcNow, ModifiedById = loginDTO.UserId, ModifiedOn = DateTime.UtcNow }; await _CertificateDAL.SaveCertificate(newCert, loginDTO); await _CertificateDAL.MarkCertRenewed(dto.TrainingCertificateId, loginDTO); var oldCacheKey = new CacheKeyGeneration().KeyGeneration(dto.TrainingCertificateId, EntityConstant.OBJECTTRAININGCERTIFICATE, CacheKeyLevel.CLIENT_LEVEL, loginDTO); await _KeyInvalidate.AllInvalidateCache(oldCacheKey); var newCacheKey = new CacheKeyGeneration().KeyGeneration(newCert.TrainingCertificateId, EntityConstant.OBJECTTRAININGCERTIFICATE, CacheKeyLevel.CLIENT_LEVEL, loginDTO); await _KeyInvalidate.AllInvalidateCache(newCacheKey); await IssuePdfNonFatalAsync(newCert, loginDTO); return string.Format(SuccessResponse.SaveSuccessWithCodeAndName, newCert.CertificateNumber, newCert.CertificateTitle); } catch (ValidationException vex) { throw new Exception(vex.Message); } catch (Exception) { if (autoNumberDTO != null) await _AutoNumber.RollbackAutoNumber("TRAININGCERTIFICATE", autoNumberDTO.StartNumber, loginDTO); throw; } } public async Task GetSelectList(CriteriaDTO criteriaDTO, LoginDTO login, CancellationToken ct) => await _CertificateDAL.GetSelectList(criteriaDTO, login, ct); public async Task SaveCertificateBulk(CertificateBulkDTO dto, LoginDTO loginDTO) { if (dto == null) throw new ArgumentNullException(nameof(dto)); if (dto.Records == null || dto.Records.Count == 0) throw new Exception("Records list cannot be empty."); AutoNumberDTO? autoNumberDTO = null; var toInsert = dto.Records.Where(r => r.TrainingCertificateId == 0).ToList(); var toUpdate = dto.Records.Where(r => r.TrainingCertificateId != 0).ToList(); try { if (toInsert.Count > 0) { autoNumberDTO = await _AutoNumber.GetAutoNumber(toInsert.Count, "TRAININGCERTIFICATE", loginDTO); int nextId = autoNumberDTO.StartNumber; foreach (var record in toInsert) { record.TrainingCertificateId = nextId++; record.TenantId = loginDTO.ClientId; record.SourceType = 5; record.CreatedById = loginDTO.UserId; record.CreatedOn = DateTime.UtcNow; record.ModifiedById = loginDTO.UserId; record.ModifiedOn = DateTime.UtcNow; } await _CertificateDAL.SaveCertificateBulk(toInsert, loginDTO); } if (toUpdate.Count > 0) { foreach (var record in toUpdate) { record.TenantId = loginDTO.ClientId; record.SourceType = 5; record.ModifiedById = loginDTO.UserId; record.ModifiedOn = DateTime.UtcNow; } await _CertificateDAL.UpdateCertificateBulk(toUpdate, loginDTO); } return $"Bulk operation completed. Saved: {toInsert.Count}, Updated: {toUpdate.Count}."; } catch (Exception) { if (autoNumberDTO != null && toInsert.Count > 0) await _AutoNumber.RollbackAutoNumber("TRAININGCERTIFICATE", autoNumberDTO.StartNumber, loginDTO); throw; } } public async Task RevokeCertificate(RevokeCertificateDTO dto, LoginDTO loginDTO) { try { if (dto == null) throw new ArgumentNullException(nameof(dto)); await _Validation.NotEmpty(dto.RevokedReason, nameof(dto.RevokedReason)); var cert = await _CertificateDAL.GetCertificate(dto.TrainingCertificateId, loginDTO); if (cert.CertStatus == (byte)CertStatus.Revoked) throw new Exception("Certificate is already revoked."); int result = await _CertificateDAL.RevokeCertificate(dto.TrainingCertificateId, dto.RevokedReason, loginDTO.UserId, loginDTO); if (result > 0) { var cacheKey = new CacheKeyGeneration().KeyGeneration(dto.TrainingCertificateId, EntityConstant.OBJECTTRAININGCERTIFICATE, CacheKeyLevel.CLIENT_LEVEL, loginDTO); await _KeyInvalidate.AllInvalidateCache(cacheKey); return string.Format(SuccessResponse.UpdateSuccessWithCodeAndName, cert.CertificateNumber, cert.CertificateTitle); } return $"{ErrorResponse.UpdateErrorMessage} {dto.TrainingCertificateId}"; } catch (ValidationException vex) { throw new Exception(vex.Message); } catch (Exception) { throw; } } public async Task VerifyByToken(string verificationToken, string? databaseName, CancellationToken ct) { try { GB5Trace.Step("verify-certificate-by-token", new { }); if (string.IsNullOrWhiteSpace(databaseName)) return JsonConvert.SerializeObject(new CertificateVerificationDTO { Valid = false, Reason = "NOT_FOUND" }); // Cross-tenant, pre-authentication lookup — mirrors FlsRespondentDAL.GetByTokenAsync's // ClientId=0/UserId=-1 precedent for "no real tenant session exists yet". The token // itself is still the entire access-control mechanism (it's the WHERE clause); the // tenant DB just has to come from somewhere, since a QR scan carries no Login header — // CertificatePdfPipeline.BuildVerifyUrl embeds it in the link as &db=. var row = await _CertificateDAL.GetCertificateByVerificationToken(verificationToken, new LoginDTO { ClientId = 0, UserId = -1, DatabaseName = databaseName }, ct).ConfigureAwait(false); if (row == null) return JsonConvert.SerializeObject(new CertificateVerificationDTO { Valid = false, Reason = "NOT_FOUND" }); bool isExpiredByDate = row.ExpiryDate.HasValue && row.ExpiryDate.Value < DateTime.UtcNow; bool isRevoked = row.CertStatus == (byte)CertStatus.Revoked; bool isExpired = row.CertStatus == (byte)CertStatus.Expired || isExpiredByDate; row.Valid = !isRevoked && !isExpired; row.Status = isRevoked ? "Revoked" : isExpired ? "Expired" : "Active"; row.Reason = row.Valid ? null : row.Status.ToUpperInvariant(); return JsonConvert.SerializeObject(row); } catch (Exception ex) { GB5Trace.MarkFailed("verify-certificate-by-token-failed", ex); throw; } } public async Task GetAttachmentIdByCertificate(int trainingCertificateId, LoginDTO loginDTO, CancellationToken ct) => await _CertificateDAL.GetAttachmentIdByCertificate(trainingCertificateId, loginDTO, ct).ConfigureAwait(false); public async Task GetAttachmentStorageInfo(int attachmentId, LoginDTO loginDTO, CancellationToken ct) => await _CertificateDAL.GetAttachmentStorageInfo(attachmentId, loginDTO, ct).ConfigureAwait(false); public async Task RegenerateCertificatePdf(int trainingCertificateId, LoginDTO loginDTO, CancellationToken ct) { try { var dto = await _CertificateDAL.GetCertificate(trainingCertificateId, loginDTO).ConfigureAwait(false); if (dto == null) throw new Exception($"Certificate {trainingCertificateId} not found."); await _CertificatePdfPipeline.IssueAsync(dto, loginDTO, ct).ConfigureAwait(false); return $"Certificate {trainingCertificateId} PDF regenerated successfully."; } catch (Exception) { throw; } } } }