using System.Text.Json; using AnalyticsBLL.AnalysisAggregation; using AnalyticsDAL.CustomCode.BIView; using AnalyticsDAL.DTO.AnalysisAggregation.V1; using AnalyticsDAL.DTO.BIView; using GB5Shared.DaprCache; using GB5Shared.DTO.Framework.AutoNumber; using GB5Shared.DTO.Framework.Login; using GB5Shared.GenerateAutoNumber; using GB5Shared.Resource.Response; using GB5Shared.Telemetry; using GB5Shared.Validation; using Microsoft.Extensions.Logging; using static GB5Shared.GB5Constant.Constant; namespace AnalyticsBLL.BIView { public class BIViewBLL : IBIViewBLL { private static readonly string[] AllowedWidgetTypes = { "kpi-card", "apex-chart", "table", "pivot-table" }; private readonly IBIViewDAL _biViewDAL; private readonly IAnalysisAggregationService _aggregationService; private readonly AutoNumber _autoNumber; private readonly IValidation _validation; private readonly KeyInvalidate _keyInvalidate; private readonly ILogger _logger; public BIViewBLL( IBIViewDAL biViewDAL, IAnalysisAggregationService aggregationService, AutoNumber autoNumber, IValidation validation, KeyInvalidate keyInvalidate, ILogger logger) { _biViewDAL = biViewDAL; _aggregationService = aggregationService; _autoNumber = autoNumber; _validation = validation; _keyInvalidate = keyInvalidate; _logger = logger; } // Mirrors GetSelectListBIView.cs/GetBIView.cs's own GetCacheKey() exactly — both must be // invalidated after any write, or a stale list/single-item response survives it (caught // live: GetSelectListBIView kept returning an empty list after the very first SaveBIView // ever run, because nothing invalidated its CLIENT_LEVEL cache entry). private async Task InvalidateCaches(int biCatalogId, int biViewId, LoginDTO login, CancellationToken ct) { var listKey = new CacheKeyGeneration().KeyGeneration( $"{biCatalogId}-views", EntityConstant.OBJECTBIVIEW, CacheKeyLevel.CLIENT_LEVEL, login); await _keyInvalidate.AllInvalidateCache(listKey).ConfigureAwait(false); var itemKey = new CacheKeyGeneration().KeyGeneration( biViewId, EntityConstant.OBJECTBIVIEW, CacheKeyLevel.CLIENT_LEVEL, login); await _keyInvalidate.AllInvalidateCache(itemKey).ConfigureAwait(false); } public async Task GetBIView(int biViewId, LoginDTO login, CancellationToken ct) => await _biViewDAL.GetById(biViewId, login, ct).ConfigureAwait(false); public async Task> GetSelectListByCatalogId(int biCatalogId, LoginDTO login, CancellationToken ct) => await _biViewDAL.GetSelectListByCatalogId(biCatalogId, login, ct).ConfigureAwait(false); // Widget-type-vs-PortletType governance (Phase 4's SupportedWidgetTypes allow-list) is // deliberately NOT checked here — a BIView has no PortletTypeId of its own (that's the // whole point of keeping it independent of MPORTLET, see the plan's Phase 1 rationale), // and the same saved view could end up referenced by portlets of different types later. // That check belongs where a Portlet actually adopts a BIViewId (PortletBLL.SavePortlet, // Phase 3/4), not here. public async Task Save(BIViewDTO dto, LoginDTO login, CancellationToken ct) { GB5Trace.Step("validate-biview", new { dto.BIViewId, dto.BICatalogId }); await _validation.NotEmpty(dto.BIViewCode, nameof(dto.BIViewCode)); await _validation.NotEmpty(dto.BIViewName, nameof(dto.BIViewName)); if (!AllowedWidgetTypes.Contains(dto.WidgetType)) throw new ArgumentException( $"WidgetType '{dto.WidgetType}' is not one of the recognized widget types ({string.Join(", ", AllowedWidgetTypes)}).", nameof(dto.WidgetType)); AnalysisQueryDefinition definition; try { definition = JsonSerializer.Deserialize(dto.BIDefinitionJson) ?? throw new ArgumentException("BIDefinitionJson deserialized to null.", nameof(dto.BIDefinitionJson)); } catch (JsonException ex) { throw new ArgumentException($"BIDefinitionJson is not a valid AnalysisQueryDefinition: {ex.Message}", nameof(dto.BIDefinitionJson)); } // Ensures the saved definition points at the same catalog the BIView row itself // claims to belong to — a mismatch here would let a view silently query a dataset // other than the one it's filed under. if (definition.DatasetId != dto.BICatalogId) throw new ArgumentException( $"BIDefinitionJson's DatasetId ({definition.DatasetId}) does not match BICatalogId ({dto.BICatalogId}).", nameof(dto.BIDefinitionJson)); // The same server-side Additivity/Aggregation trust check RunBIQuery applies at // query time — reject at save time rather than let a bad definition sit dormant // until whoever's dashboard hits it discovers the rejection live. await _aggregationService.ValidateDefinitionAsync(definition, login, ct).ConfigureAwait(false); dto.ModifiedById = login.UserId; dto.ModifiedOn = DateTime.UtcNow; AutoNumberDTO? autoNumberDTO = null; try { if (dto.BIViewId == 0) { autoNumberDTO = await _autoNumber.GetAutoNumber(1, "BIVIEW", login).ConfigureAwait(false); dto.BIViewId = autoNumberDTO.StartNumber; dto.TenantId = login.ClientId; dto.CreatedById = login.UserId; dto.CreatedOn = DateTime.UtcNow; GB5Trace.Step("save-biview", new { dto.BIViewId, isNew = true }); await _biViewDAL.Insert(dto, login, ct).ConfigureAwait(false); await InvalidateCaches(dto.BICatalogId, dto.BIViewId, login, ct).ConfigureAwait(false); return $"{SuccessResponse.SaveSuccessMessage} {dto.BIViewId}"; } GB5Trace.Step("save-biview", new { dto.BIViewId, isNew = false }); await _biViewDAL.Update(dto, login, ct).ConfigureAwait(false); await InvalidateCaches(dto.BICatalogId, dto.BIViewId, login, ct).ConfigureAwait(false); return $"{SuccessResponse.UpdateSuccessMessage} {dto.BIViewId}"; } catch (Exception ex) { GB5Trace.MarkFailed("save-biview-failed", ex); _logger.LogError(ex, "BIViewBLL.Save failed for BIViewId {BIViewId}", dto.BIViewId); if (autoNumberDTO != null) await _autoNumber.RollbackAutoNumber("BIVIEW", dto.BIViewId, login).ConfigureAwait(false); throw; } } public async Task Delete(int biViewId, LoginDTO login, CancellationToken ct) { GB5Trace.Step("delete-biview", new { biViewId }); // Fetched before the delete purely to know which catalog's list cache to invalidate — // the row itself no longer matters once GetById has returned it. var existing = await _biViewDAL.GetById(biViewId, login, ct).ConfigureAwait(false); await _biViewDAL.Delete(biViewId, login, ct).ConfigureAwait(false); if (existing != null) await InvalidateCaches(existing.BICatalogId, biViewId, login, ct).ConfigureAwait(false); return SuccessResponse.DeleteSuccessMessage; } } }