using AmpDAL.CustomCode.ApiRequest; using AmpDAL.DTO.ApiRequest; using AMPBLL.ApiRequestChecklist; using AMPBLL.ApiRequestDuplicateCheck; using AMPBLL.ApiRequestSubscriber; using AMPBLL.ApiRequestTimeline; using GB5Shared.DaprCache; using GB5Shared.DTO.Framework.Login; using GB5Shared.EventLogPublish; using GB5Shared.GB5Constant; using GB5Shared.GenerateAutoNumber; using GB5Shared.Resource.Response; using Microsoft.Extensions.Logging; using GB5Shared.DTO.Framework.Criteria; namespace AMPBLL.ApiRequest; public class ApiRequestBLL( IApiRequestDAL _dal, IApiRequestTimelineBLL _timeline, IApiRequestChecklistBLL _checklist, IApiRequestSubscriberBLL _subscriber, IApiRequestDuplicateCheckBLL _duplicateCheck, AutoNumber _autoNumber, KeyInvalidate _keyInvalidate, EventLogPublish _eventLog, ILogger _logger ) : IApiRequestBLL { // Forward-only allowed transitions (key = from, value = allowed destinations) private static readonly IReadOnlyDictionary> AllowedTransitions = new Dictionary> { [0] = [1], // Submitted → UnderReview [1] = [2, 3], // UnderReview → Accepted or Rejected [2] = [4], // Accepted → Planning [4] = [5], // Planning → InDevelopment [5] = [6], // InDevelopment → DevReview [6] = [7], // DevReview → QCTesting [7] = [8], // QCTesting → UAT [8] = [9], // UAT → ReadyToDeploy [9] = [10], // ReadyToDeploy → Deployed }; // Stages from which OnHold and Cancelled are permitted private static readonly IReadOnlySet ActiveStages = new HashSet { 1, 2, 4, 5, 6, 7, 8, 9 }; // Stages that have checklist items seeded on entry // Stages 0,1,3,10,11,12 are system-managed — no checklist seeding required private static readonly IReadOnlySet SeededStages = new HashSet { 2, 4, 5, 6, 7, 8, 9 }; // ===================================================== // GET // ===================================================== public async Task GetRequestAsync(int apiRequestId, LoginDTO login, CancellationToken ct) { try { return await _dal.GetRequestAsync(apiRequestId, login.ClientId, login, ct).ConfigureAwait(false); } catch (Exception ex) { _logger.LogError(ex, "GetRequest failed for ApiRequestId {ApiRequestId}", apiRequestId); throw; } } public async Task> GetRequestListAsync(LoginDTO login, CancellationToken ct) { try { return await _dal.GetRequestListAsync(login, ct).ConfigureAwait(false); } catch (Exception ex) { _logger.LogError(ex, "GetRequestList failed TenantId {TenantId}", login.ClientId); throw; } } public async Task> GetPagedListAsync(int firstNumber, int maxResult, LoginDTO login, CancellationToken ct) { if (firstNumber != -1 && firstNumber < 1) throw new ArgumentException($"FirstNumber must be >= 1 or -1. Got: {firstNumber}"); if (maxResult != -1 && maxResult < 1) throw new ArgumentException($"MaxResult must be >= 1 or -1. Got: {maxResult}"); int resolvedFirstNumber = firstNumber == -1 ? 1 : firstNumber; int resolvedMaxResult = maxResult == -1 ? int.MaxValue : maxResult; try { return await _dal.GetPagedListAsync(resolvedFirstNumber, resolvedMaxResult, login, ct).ConfigureAwait(false); } catch (Exception ex) { _logger.LogError(ex, "GetPagedList failed TenantId {TenantId}", login.ClientId); throw; } } public async Task> GetSelectListAsync(CriteriaDTO criteriaDTO, LoginDTO login, CancellationToken ct) { try { return await _dal.GetSelectListAsync(criteriaDTO, login, ct).ConfigureAwait(false); } catch (Exception ex) { _logger.LogError(ex, "GetSelectList failed TenantId {TenantId}", login.ClientId); throw; } } // ===================================================== // SAVE // ===================================================== public async Task SaveRequestAsync(ApiRequestSaveDTO dto, LoginDTO login, CancellationToken ct) { if (string.IsNullOrWhiteSpace(dto.RequestTitle)) throw new ArgumentException("RequestTitle is required."); if (string.IsNullOrWhiteSpace(dto.RequestDescription)) throw new ArgumentException("RequestDescription is required."); dto.TenantId = login.ClientId; bool isNew = dto.ApiRequestId == 0; int requestId; string requestCode; if (isNew) { // ✅ GetNumberAsync — consistent with rest of system var autoNumberDto = await _autoNumber.GetNumberAsync(1, "APIREQUEST", login).ConfigureAwait(false); dto.ApiRequestId = autoNumberDto.StartNumber; dto.RequestCode = GenerateRequestCode(autoNumberDto.StartNumber); requestId = await _dal.SaveRequestAsync(dto, login, ct).ConfigureAwait(false); requestCode = dto.RequestCode; // Initial timeline entry (null → Submitted) await _timeline.AddTimelineEntryAsync(requestId, null, 0, "Request submitted.", login, ct).ConfigureAwait(false); } else { await _dal.UpdateRequestAsync(dto, login, ct).ConfigureAwait(false); requestId = dto.ApiRequestId; requestCode = dto.RequestCode; } await InvalidateCacheAsync(login, requestId); try { await _eventLog.PublishEventLogAsync( isNew ? "AMP Request Submitted" : "AMP Request Updated", new { dto.ApiRequestId, dto.RequestTitle }, Constant.EventTypeConstant.SAVEDOCUMENTSET, 0, login).ConfigureAwait(false); } catch (Exception ex) { _logger.LogWarning(ex, "Event publish failed for request {RequestId}", requestId); } // Duplicate check — non-fatal, new requests only var (duplicatesFound, topMatches) = (0, new List()); if (isNew) { try { (duplicatesFound, topMatches) = await _duplicateCheck.RunDuplicateCheckAsync(requestId, login, ct).ConfigureAwait(false); } catch (Exception ex) { _logger.LogWarning(ex, "Duplicate check failed for request {RequestId} — proceeding without results", requestId); } } return new ApiRequestSaveResult { RequestId = requestId, RequestCode = requestCode, DuplicatesFound = duplicatesFound, TopMatches = topMatches.Select(m => new DuplicateMatchSummaryDTO { OverallScore = m.OverallScore, SuggestionTypeName = SuggestionTypeLabel(m.SuggestionType), MatchedName = m.MatchReason ?? string.Empty, SuggestedChange = m.SuggestedChange, MatchSource = m.MatchSource }).ToList(), Message = isNew ? $"{SuccessResponse.SaveSuccessMessage} {requestId}" : $"{SuccessResponse.UpdateSuccessMessage} {requestId}" }; } public async Task DeleteRequestAsync(int apiRequestId, LoginDTO login, CancellationToken ct) { if (apiRequestId == 0) throw new ArgumentException("ApiRequestId must be a positive number."); try { await _dal.DeleteRequestAsync(apiRequestId, login.UserId, login, ct).ConfigureAwait(false); await InvalidateCacheAsync(login, apiRequestId); return SuccessResponse.DeleteSuccessMessage; } catch (Exception ex) { _logger.LogError(ex, "DeleteRequest failed for ApiRequestId {ApiRequestId}", apiRequestId); throw; } } // ===================================================== // STATUS TRANSITION // ===================================================== public async Task UpdateRequestStatusAsync(int apiRequestId, byte requestStatus, string? comments, LoginDTO login, CancellationToken ct) { if (apiRequestId == 0) throw new ArgumentException("ApiRequestId must be a positive number."); var current = await _dal.GetRequestAsync(apiRequestId, login.ClientId, login, ct).ConfigureAwait(false) ?? throw new InvalidOperationException($"Request {apiRequestId} not found."); byte currentStatus = current.RequestStatus; // Validate transition bool isHoldOrCancel = requestStatus is 11 or 12; bool isForward = AllowedTransitions.TryGetValue(currentStatus, out var allowed) && allowed.Contains(requestStatus); if (!isForward && !(isHoldOrCancel && ActiveStages.Contains(currentStatus))) throw new InvalidOperationException($"Transition from Stage {currentStatus} to Stage {requestStatus} is not allowed."); // Comments required for Rejected, OnHold, Cancelled if (requestStatus is 3 or 11 or 12 && string.IsNullOrWhiteSpace(comments)) throw new ArgumentException("Comments are required when rejecting, placing on hold, or cancelling a request."); // ✅ Checklist gate — block forward transition if current stage has incomplete items if (isForward && SeededStages.Contains(currentStatus)) { var checklist = await _checklist.GetChecklistAsync(apiRequestId, currentStatus, login, ct).ConfigureAwait(false); var incomplete = checklist.Where(x => x.IsComplete == 0).ToList(); if (incomplete.Count > 0) throw new InvalidOperationException( $"Cannot transition from Stage {currentStatus} to Stage {requestStatus}: " + $"{incomplete.Count} checklist item(s) are still incomplete. " + $"Complete all items before proceeding."); } // Hard BLL gates per target status await EnforceGatesAsync(current, requestStatus, login, ct).ConfigureAwait(false); // Execute transition await _dal.UpdateRequestStatusAsync(apiRequestId, requestStatus, login.UserId, login, ct).ConfigureAwait(false); // Timeline entry await _timeline.AddTimelineEntryAsync(apiRequestId, currentStatus, requestStatus, comments, login, ct).ConfigureAwait(false); // ✅ Seed checklist only for stages that have items (0,1,3,10,11,12 skipped) if (SeededStages.Contains(requestStatus)) await _checklist.SeedStageItemsAsync(apiRequestId, requestStatus, login, ct).ConfigureAwait(false); // Notify subscribers await _subscriber.NotifySubscribersAsync(apiRequestId, requestStatus, login, ct).ConfigureAwait(false); await InvalidateCacheAsync(login, apiRequestId); try { await _eventLog.PublishEventLogAsync( "AMP Request Status Updated", new { apiRequestId, From = currentStatus, To = requestStatus }, Constant.EventTypeConstant.SAVEDOCUMENTSET, 0, login).ConfigureAwait(false); } catch (Exception ex) { _logger.LogWarning(ex, "Event publish failed for status update {ApiRequestId}", apiRequestId); } return $"{SuccessResponse.UpdateSuccess} {apiRequestId}"; } // ===================================================== // LINK API // ===================================================== public async Task LinkApiToRequestAsync(int apiRequestId, int linkedApiId, int linkedApiVersionId, LoginDTO login, CancellationToken ct) { if (apiRequestId == 0) throw new ArgumentException("ApiRequestId must be a positive number."); if (linkedApiId == 0) throw new ArgumentException("LinkedApiId must be a positive number."); if (linkedApiVersionId == 0) throw new ArgumentException("LinkedApiVersionId must be a positive number."); try { await _dal.LinkApiToRequestAsync(apiRequestId, linkedApiId, linkedApiVersionId, login.UserId, login, ct).ConfigureAwait(false); await InvalidateCacheAsync(login, apiRequestId); return $"{SuccessResponse.UpdateSuccess} {apiRequestId}"; } catch (Exception ex) { _logger.LogError(ex, "LinkApiToRequest failed for ApiRequestId {ApiRequestId}", apiRequestId); throw; } } // ===================================================== // READINESS SCORE (0–100) // ===================================================== public async Task GetReadinessScoreAsync(int apiRequestId, LoginDTO login, CancellationToken ct) { try { var request = await _dal.GetRequestAsync(apiRequestId, login.ClientId, login, ct).ConfigureAwait(false); if (request?.LinkedApiId is null or <= 0) return 0; var components = await _dal.GetReadinessComponentsAsync(request.LinkedApiId, login.ClientId, login, ct).ConfigureAwait(false); if (components is null) return 0; int score = 0; // Interface exists and parsed (20 pts) if (components.ParsedInterfaceCount > 0) score += 20; // Schema with content (20 pts) if (components.SchemaWithContentCount > 0) score += 20; // Operations with all parameters set (15 pts) if (components.OperationCount > 0 && components.UnsetParamCount == 0) score += 15; // Content fields: ABOUTAPI + KEYFEATURES + USECASES + QUICKSTART (25 pts) score += (components.ContentFieldsComplete * 25) / 4; // Artifact exists (10 pts) if (components.ArtifactCount > 0) score += 10; // Exposure policy exists (10 pts) if (components.ExposurePolicyCount > 0) score += 10; return Math.Min(score, 100); } catch (Exception ex) { _logger.LogError(ex, "GetReadinessScore failed for ApiRequestId {ApiRequestId}", apiRequestId); throw; } } // ── Private helpers ──────────────────────────────────────────── private async Task EnforceGatesAsync(ApiRequestDTO current, byte targetStatus, LoginDTO login, CancellationToken ct) { switch (targetStatus) { case 2: // → Accepted: no unresolved duplicates int pendingCount = await _dal.GetPendingDuplicateCountAsync(current.ApiRequestId, login.ClientId, login, ct).ConfigureAwait(false); if (pendingCount > 0) throw new InvalidOperationException( $"Cannot accept request: {pendingCount} unresolved duplicate check(s) remain. Review via ApiRequestDuplicateCheck."); break; case 5: // → InDevelopment: LINKEDAPIID must be set if (current.LinkedApiId <= 0) throw new InvalidOperationException( "Cannot move to InDevelopment: LINKEDAPIID must be set (create MAPI draft first via LinkApiToRequest)."); break; case 6: // → DevReview: at least one MAPIINTERFACE must exist int interfaceCount = await _dal.GetInterfaceCountAsync(current.LinkedApiId, login.ClientId, login, ct).ConfigureAwait(false); if (interfaceCount == 0) throw new InvalidOperationException( "Cannot move to DevReview: no MAPIINTERFACE records found for the linked API."); break; case 7: // → QCTesting: all interfaces must be parsed int unparsedCount = await _dal.GetUnparsedInterfaceCountAsync(current.LinkedApiId, login.ClientId, login, ct).ConfigureAwait(false); if (unparsedCount > 0) throw new InvalidOperationException( $"Cannot move to QCTesting: {unparsedCount} interface(s) have not been parsed successfully."); break; case 8: // → UAT: schema must have content AND all parameters set int schemaCount = await _dal.GetSchemaWithContentCountAsync(current.LinkedApiId, login.ClientId, login, ct).ConfigureAwait(false); if (schemaCount == 0) throw new InvalidOperationException( "Cannot move to UAT: no MAPISCHEMA records with request and response JSON found."); int unsetParams = await _dal.GetUnsetParameterCountAsync(current.LinkedApiId, login.ClientId, login, ct).ConfigureAwait(false); if (unsetParams > 0) throw new InvalidOperationException( $"Cannot move to UAT: {unsetParams} MAPIPARAMETER row(s) have ISREQUIRED not set."); break; case 9: // → ReadyToDeploy: API content fields and artifact required int completeness = await _dal.GetApiContentCompletenessAsync(current.LinkedApiId, login.ClientId, login, ct).ConfigureAwait(false); if (completeness < 3) throw new InvalidOperationException( "Cannot move to ReadyToDeploy: MAPI ABOUTAPI, KEYFEATURES, and USECASES must all be populated."); int artifactCount = await _dal.GetArtifactCountAsync(current.LinkedApiId, login.ClientId, login, ct).ConfigureAwait(false); if (artifactCount == 0) throw new InvalidOperationException( "Cannot move to ReadyToDeploy: at least one MAPIARTIFACT record is required."); break; case 10: // → Deployed: exposure policy exists and version is Active int policyCount = await _dal.GetExposurePolicyCountAsync(current.LinkedApiId, login.ClientId, login, ct).ConfigureAwait(false); if (policyCount == 0) throw new InvalidOperationException( "Cannot deploy: MAPIEXPOSUREPOLICY record is required before deployment."); if (current.LinkedApiVersionId <= 0) throw new InvalidOperationException( "Cannot deploy: LINKEDAPIVERSIONID must be set."); var versionStatus = await _dal.GetVersionStatusAsync(current.LinkedApiVersionId, login.ClientId, login, ct).ConfigureAwait(false); if (versionStatus != 1) throw new InvalidOperationException( "Cannot deploy: the linked API version must have VERSIONSTATUS = 1 (Active)."); break; } } private async Task InvalidateCacheAsync(LoginDTO login, int requestId) { var cacheKey = new CacheKeyGeneration().KeyGeneration( login.ClientId.ToString(), Constant.EntityConstant.OBJECTAMPAPI, Constant.CacheKeyLevel.CLIENT_LEVEL, login); try { await _keyInvalidate.InvalidateCache(cacheKey).ConfigureAwait(false); } catch (Exception ex) { _logger.LogWarning(ex, "Cache invalidation failed for request {RequestId}", requestId); } } private static string GenerateRequestCode(int id) => $"ASR-{DateTime.UtcNow.Year}-{id:D5}"; private static string SuggestionTypeLabel(byte type) => type switch { 0 => "Duplicate", 1 => "ExtendWithParameter", 2 => "ExtendWithField", 3 => "AddNewEndpoint", 4 => "CreateNewVersion", _ => "Unknown" }; public async Task UpdateRequestAsync(ApiRequestSaveDTO dto, LoginDTO login, CancellationToken ct) { if (dto.ApiRequestId == 0) throw new ArgumentException("ApiRequestId must be a positive number for update."); if (string.IsNullOrWhiteSpace(dto.RequestTitle)) throw new ArgumentException("RequestTitle is required."); if (string.IsNullOrWhiteSpace(dto.RequestDescription)) throw new ArgumentException("RequestDescription is required."); dto.TenantId = login.ClientId; try { await _dal.UpdateRequestAsync(dto, login, ct).ConfigureAwait(false); await InvalidateCacheAsync(login, dto.ApiRequestId); try { await _eventLog.PublishEventLogAsync( "AMP Request Updated", new { dto.ApiRequestId, dto.RequestTitle }, Constant.EventTypeConstant.SAVEDOCUMENTSET, 0, login).ConfigureAwait(false); } catch (Exception ex) { _logger.LogWarning(ex, "Event publish failed for update {ApiRequestId}", dto.ApiRequestId); } return $"{SuccessResponse.UpdateSuccessMessage} {dto.ApiRequestId}"; } catch (Exception ex) { _logger.LogError(ex, "UpdateRequest failed for ApiRequestId {ApiRequestId}", dto.ApiRequestId); throw; } } }