using CMSBLL.Channel; using EnablementDAL.CustomCode.ShowContentLink; using EnablementDAL.DTO; using GB5Shared.DTO.Framework.Enum; using GB5Shared.DTO.Framework.Login; using GB5Shared.DTO.Framework.ResponseStandard; using GB5Shared.EventLogPublish; using GB5Shared.GB5Exception; using GB5Shared.GenerateAutoNumber; using GB5Shared.Resource.Response; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using System.Text.Json; using TMSBLL.Quiz; using TMSBLL.TrainingProgrammeSessionTopic; using TMSDAL.DTO.Quiz; using TMSDAL.DTO.TrainingProgramme; using WiBLL.WiMaster; using WiDAL.DTOs; namespace EnablementBLL.ShowContentLink; public class ShowContentLinkBLL : IShowContentLinkBLL { private const byte LINKTYPE_INTERNAL_CMS = 1; private const byte LINKTYPE_DOCUMENT = 5; private const byte LINKTYPE_WORK_INSTRUCTION = 6; private const byte LINKTYPE_TRAINING = 7; private const byte LINKTYPE_QUIZ = 8; private const byte DISPLAYHINT_DOWNLOAD = 3; private readonly IShowContentLinkDAL _dal; private readonly IChannelBLL _cmsChannelBLL; private readonly IWiMasterBLL _wiMasterBLL; private readonly ITrainingProgrammeSessionTopicBLL _topicBLL; private readonly IQuizBLL _quizBLL; private readonly AutoNumber _autoNumber; private readonly EventLogPublish _eventLog; private readonly ILogger? _logger; public ShowContentLinkBLL( IShowContentLinkDAL dal, IChannelBLL cmsChannelBLL, IWiMasterBLL wiMasterBLL, ITrainingProgrammeSessionTopicBLL topicBLL, IQuizBLL quizBLL, AutoNumber autoNumber, EventLogPublish eventLog, ILogger? logger = null) { _dal = dal; _cmsChannelBLL = cmsChannelBLL; _wiMasterBLL = wiMasterBLL; _topicBLL = topicBLL; _quizBLL = quizBLL; _autoNumber = autoNumber; _eventLog = eventLog; _logger = logger; } public async Task> GetShowContentLinkAsync(int showContentLinkId, LoginDTO login, CancellationToken ct) { // Real GB5 IDs (AutoNumber-generated, like ShowContentLinkId) are commonly large negative // numbers — "<= 0" wrongly rejects them; -1/0 are the actual "not selected" sentinels. Same // fix applied throughout this file (found live-blocking Slice 5's own verification). if (showContentLinkId == 0 || showContentLinkId == -1) throw new ArgumentException("ShowContentLinkId must be selected."); var dto = await _dal.GetShowContentLinkAsync(showContentLinkId, login, ct).ConfigureAwait(false); if (dto is null) throw new NotFoundException($"ShowContentLink {showContentLinkId} not found."); return OkResponse(dto); } public async Task> GetShowContentLinkListAsync( int showId, int stepId, int pageNo, int pageSize, LoginDTO login, CancellationToken ct) { if (showId == 0 || showId == -1) throw new ArgumentException("ShowId must be selected."); if (pageNo < 1) throw new ArgumentException("PageNo must be >= 1."); if (pageSize < 1) throw new ArgumentException("PageSize must be >= 1."); var items = await _dal.GetShowContentLinkListAsync(showId, stepId, pageNo, pageSize, login, ct).ConfigureAwait(false); int total = await _dal.GetShowContentLinkListCountAsync(showId, stepId, login, ct).ConfigureAwait(false); // Refresh LinkTitle from the live WI for any stored WorkInstruction link — same // freshness goal as the CMS auto-link block below, but for a manually-created row // (ContentId = WiId; StepId doubles as the optional deep-linked WiStepId per // ShowContentLinkDTO's comment) rather than a live-discovered whole channel. foreach (var item in items) { if (item.LinkType != LINKTYPE_WORK_INSTRUCTION || item.ContentId == 0 || item.ContentId == -1) continue; try { var wiJson = await _wiMasterBLL.GetWiById(item.ContentId, login, ct).ConfigureAwait(false); var wi = System.Text.Json.JsonSerializer.Deserialize(wiJson); if (!string.IsNullOrWhiteSpace(wi?.WiTitle)) item.LinkTitle = wi.WiTitle; } catch (Exception ex) { // WI deleted/archived/unreachable — fall back to the stored LinkTitle rather // than failing the whole content-link list for one bad reference. _logger?.LogWarning(ex, "Failed to refresh WI title for ShowContentLinkId={ShowContentLinkId} WiId={WiId}", item.ShowContentLinkId, item.ContentId); } } // Part B, Slice 5 — refresh LinkTitle from the live Training Topic, same freshness // goal and fall-back-on-failure pattern as the WI block above. ContentId is always // SessionTopicId for this LinkType (never ProgrammeId — see V009 migration header), // so a single lookup gives title + LMS pointers for the FE's "Open in LMS" action. foreach (var item in items) { if (item.LinkType != LINKTYPE_TRAINING || item.ContentId == 0 || item.ContentId == -1) continue; try { var topicJson = await _topicBLL.GetProgrammeSessionTopics(item.ContentId, login).ConfigureAwait(false); var topic = JsonConvert.DeserializeObject(topicJson); if (!string.IsNullOrWhiteSpace(topic?.TopicTitle)) item.LinkTitle = topic.TopicTitle; if (topic is not null) { if (topic.LmsContentId > 0) item.LmsContentId = topic.LmsContentId; if (topic.LmsCourseId > 0) item.LmsCourseId = topic.LmsCourseId; } } catch (Exception ex) { // Topic deleted/unreachable — fall back to the stored LinkTitle rather than // failing the whole content-link list for one bad reference. _logger?.LogWarning(ex, "Failed to refresh Training Topic title for ShowContentLinkId={ShowContentLinkId} SessionTopicId={SessionTopicId}", item.ShowContentLinkId, item.ContentId); } } // Quiz/Assessment roadmap Phase 1 — refresh LinkTitle from the live Quiz, same freshness // goal and fall-back-on-failure pattern as the WI/Training blocks above. ContentId is always // MQUIZ.QuizId for this LinkType (never MSESSIONASSESSMENT.AssessmentId — see V011 migration // header), since the standalone Quiz engine is deliberately decoupled from programme-bound // assessments. foreach (var item in items) { if (item.LinkType != LINKTYPE_QUIZ || item.ContentId == 0 || item.ContentId == -1) continue; try { var quizJson = await _quizBLL.GetQuizById(item.ContentId, login).ConfigureAwait(false); var quiz = JsonConvert.DeserializeObject(quizJson); if (!string.IsNullOrWhiteSpace(quiz?.QuizTitle)) item.LinkTitle = quiz.QuizTitle; } catch (Exception ex) { // Quiz deleted/unreachable — fall back to the stored LinkTitle rather than // failing the whole content-link list for one bad reference. _logger?.LogWarning(ex, "Failed to refresh Quiz title for ShowContentLinkId={ShowContentLinkId} QuizId={QuizId}", item.ShowContentLinkId, item.ContentId); } } // Part B, Slice 3 — live-pull whatever's currently Published under the Show's // optionally-linked CMS Channel, show-level only (stepId == -1). No row is ever // materialized for this; it's recomputed on every read, same "compute live, don't // materialize" idiom as ShowChannelSubscriptionBLL's eligibility computation. if (stepId == -1) { int? cmsChannelId = await _dal.GetShowCmsChannelIdAsync(showId, login, ct).ConfigureAwait(false); if (cmsChannelId.HasValue) { var channelContent = await _cmsChannelBLL.GetPublishedChannelContentAsync(cmsChannelId.Value, login, ct).ConfigureAwait(false); var autoLinked = channelContent.Select(c => new ShowContentLinkDTO { ShowId = showId, StepId = -1, LinkType = LINKTYPE_INTERNAL_CMS, ContentId = c.ContentId, LinkTitle = c.ContentTitle ?? string.Empty, IsActive = 1, IsAutoLinked = true }).ToList(); items = items.Concat(autoLinked); total += autoLinked.Count; } } return OkResponse(new { Items = items, TotalCount = total }); } public async Task> SaveShowContentLinkAsync(ShowContentLinkDTO dto, LoginDTO login, CancellationToken ct) { if (dto.ShowId == 0 || dto.ShowId == -1) throw new ArgumentException("ShowId must be selected."); if (dto.LinkType is < LINKTYPE_INTERNAL_CMS or > LINKTYPE_QUIZ) throw new ArgumentException("LinkType must be in range 1-8."); if (dto.DisplayHint > DISPLAYHINT_DOWNLOAD) throw new ArgumentException("DisplayHint must be in range 0-3."); if (dto.LinkType == LINKTYPE_INTERNAL_CMS || dto.LinkType == LINKTYPE_WORK_INSTRUCTION || dto.LinkType == LINKTYPE_TRAINING || dto.LinkType == LINKTYPE_QUIZ) { if (dto.ContentId == 0 || dto.ContentId == -1) throw new ArgumentException("ContentId is required when LinkType is InternalCMS, WorkInstruction, Training, or Quiz."); } else { if (string.IsNullOrWhiteSpace(dto.LinkUrl)) throw new ArgumentException("LinkUrl is required for this LinkType."); } dto.TenantId = login.ClientId; dto.ModifiedById = login.UserId; // ShowContentLinkId's "new record" sentinel is 0 (the FE's default/unset int, not -1 — // unlike ShowId/ContentId above, -1 isn't a meaningful value for this table's own PK). bool isNew = dto.ShowContentLinkId == 0; if (isNew) { var an = await _autoNumber.GetNumberAsync(1, "SHOWCONTENTLINK", login).ConfigureAwait(false); dto.ShowContentLinkId = an.StartNumber; dto.CreatedById = login.UserId; dto.SourceType = login.SourceType; await _dal.InsertShowContentLinkAsync(dto, login, ct).ConfigureAwait(false); _ = _eventLog.PublishEventLogAsync("ShowContentLink Created", dto, 1, dto.ShowContentLinkId, login, ct: ct); } else { await _dal.UpdateShowContentLinkAsync(dto, login, ct).ConfigureAwait(false); _ = _eventLog.PublishEventLogAsync("ShowContentLink Updated", dto, 1, dto.ShowContentLinkId, login, ct: ct); } _logger?.LogInformation( "ShowContentLink saved ShowContentLinkId={ShowContentLinkId} ShowId={ShowId} StepId={StepId}", dto.ShowContentLinkId, dto.ShowId, dto.StepId); return OkResponse(isNew ? $"{SuccessResponse.SaveSuccessMessage} {dto.ShowContentLinkId}" : SuccessResponse.UpdateSuccess); } public async Task> DeleteShowContentLinkAsync(int showContentLinkId, LoginDTO login, CancellationToken ct) { if (showContentLinkId == 0 || showContentLinkId == -1) throw new ArgumentException("ShowContentLinkId must be selected."); await _dal.DeleteShowContentLinkAsync(showContentLinkId, login, ct).ConfigureAwait(false); _ = _eventLog.PublishEventLogAsync("ShowContentLink Deleted", new { ShowContentLinkId = showContentLinkId }, 1, showContentLinkId, login, ct: ct); return OkResponse(SuccessResponse.DeleteSuccessMessage); } private static ResponseStandardDTO OkResponse(object body) => new() { Body = body, Status = FrameworkEnumDTO.ResponseStatus.Ok }; }