using EnablementDAL.CustomCode.ShowRule; using EnablementDAL.DTO; using EnablementDAL.Implementations; using GB5Shared.DTO.Framework.Criteria; 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; namespace EnablementBLL.ShowRule; public class ShowRuleBLL : IShowRuleBLL { public const byte SCOPE = 1; public const byte AUDIENCE = 2; public const byte TRIGGER = 3; private const int EQUALS = 0; private const int IN = 1; private const int NOT_EQUALS = 2; private const int STARTS_WITH = 3; private readonly IShowRuleDAL _dal; private readonly AutoNumber _autoNumber; private readonly EventLogPublish _eventLog; private readonly ILogger? _logger; public ShowRuleBLL( IShowRuleDAL dal, AutoNumber autoNumber, EventLogPublish eventLog, ILogger? logger = null) { _dal = dal; _autoNumber = autoNumber; _eventLog = eventLog; _logger = logger; } // ------------------------------------------------------------------------- // Admin CRUD (author-time) — separate from Evaluate(), which is the // resolver's read-only rule-matching path and is left untouched below. // ------------------------------------------------------------------------- // In ShowRuleBLL.cs public async Task GetShowRuleAsync(int showRuleId, LoginDTO loginDTO) { try { return await _dal.GetShowRuleAsync(showRuleId, loginDTO); } catch (Exception) { throw; } } public async Task GetShowRuleList(CriteriaDTO criteriaDTO, LoginDTO loginDTO) { try { return await _dal.GetShowRuleList(criteriaDTO, loginDTO); } catch (Exception) { throw; } } public async Task SaveShowRuleAsync( ShowRuleDTO dto, LoginDTO login, CancellationToken ct) { if (dto == null) throw new ArgumentNullException(nameof(dto)); try { // TSHOWRULE is 3-way polymorphic (see V005/V008 migrations) — a rule belongs // to exactly one owner (Show, Channel, or Space), never more than one, never // none. Real GB5 IDs are commonly large negative numbers (see // feedback_gb5_negative_autonumber_ids memory) — "> 0" would wrongly treat a // legitimately negative owner ID as unset, so the sentinels checked here are // the DTO's actual "not applicable" values (0/-1), matching ShowRuleDAL's // InsertShowRuleAsync null-out logic. int ownerCount = (dto.ShowId != 0 && dto.ShowId != -1 ? 1 : 0) + (dto.ChannelId != 0 && dto.ChannelId != -1 ? 1 : 0) + (dto.SpaceId != 0 && dto.SpaceId != -1 ? 1 : 0); if (ownerCount != 1) throw new ArgumentException("Exactly one of ShowId/ChannelId/SpaceId must be set."); if (dto.RuleCategory is < SCOPE or > TRIGGER) throw new ArgumentException("RuleCategory must be in range 1-3."); if (dto.RuleOperator > STARTS_WITH) throw new ArgumentException("RuleOperator must be in range 0-3."); if (string.IsNullOrWhiteSpace(dto.RuleKey)) throw new ArgumentException("RuleKey must not be empty."); if (string.IsNullOrWhiteSpace(dto.RuleValue)) throw new ArgumentException("RuleValue must not be empty."); dto.TenantId = login.ClientId; dto.ModifiedById = login.UserId; dto.ModifiedOn = DateTime.UtcNow; if (dto.ShowRuleId == 0) { dto.CreatedById = login.UserId; dto.CreatedOn = DateTime.UtcNow; dto.SourceType = login.SourceType; await _dal.InsertShowRuleAsync(dto, login, ct); _ = _eventLog.PublishEventLogAsync( "ShowRule Created", dto, 1, dto.ShowRuleId, login, ct: ct); return "Details Saved Successfully."; } else { await _dal.UpdateShowRuleAsync(dto, login, ct); _ = _eventLog.PublishEventLogAsync( "ShowRule Updated", dto, 1, dto.ShowRuleId, login, ct: ct); return "Details Updated Successfully."; } } catch (Exception) { throw; } } public async Task> DeleteShowRuleAsync(int showRuleId, LoginDTO login, CancellationToken ct) { if (showRuleId <= 0) throw new ArgumentException("ShowRuleId must be greater than zero."); await _dal.DeleteShowRuleAsync(showRuleId, login, ct).ConfigureAwait(false); _ = _eventLog.PublishEventLogAsync("ShowRule Deleted", new { ShowRuleId = showRuleId }, 1, showRuleId, login, ct: ct); return OkResponse(SuccessResponse.DeleteSuccessMessage); } private static ResponseStandardDTO OkResponse(object body) => new() { Body = body, Status = FrameworkEnumDTO.ResponseStatus.Ok }; // Channel-owned rules — used by ShowChannelSubscriptionBLL, not the resolver. public async Task> GetShowRulesForChannelAsync(int channelId, LoginDTO login, CancellationToken ct) => await _dal.GetShowRulesForChannelAsync(channelId, login, ct).ConfigureAwait(false); // Space-owned rules — used by ShowSpaceSubscriptionBLL, not the resolver. public async Task> GetShowRulesForSpaceAsync(int spaceId, LoginDTO login, CancellationToken ct) => await _dal.GetShowRulesForSpaceAsync(spaceId, login, ct).ConfigureAwait(false); // Minimal ResolverContextDTO carrying only User.RoleId/OuId from LoginDTO — enough // for Audience-category evaluation without needing a full EnablementContextDTO // (which is UI-event-shaped and doesn't exist outside a resolver call). Sibling to // ShowResolverBLL's private MapToResolverContext, which builds the fuller // UI/Trigger/Entity context a display-resolution call needs — that one stays // resolver-specific; this one is the shared, narrower piece both callers need. public static ResolverContextDTO BuildAudienceOnlyContext(LoginDTO login) => new() { User = new ResolverUserContextDTO { UserId = login.UserId, RoleId = login.RoleId, OuId = login.WorkOUId } }; public bool Evaluate( IEnumerable rulesForShow, byte ruleCategory, ResolverContextDTO context, IEnumerable userRoles) { var categoryRules = rulesForShow .Where(r => r.RuleCategory == ruleCategory) .ToList(); if (!categoryRules.Any()) return true; var parsedRules = categoryRules .Select(ParseExpression) .Where(p => p is not null) .Select(p => p!) .ToList(); if (!parsedRules.Any()) return true; var groups = parsedRules.GroupBy(p => p.Group); return groups.All(g => EvaluateGroup(g, context, userRoles)); } public (int Matched, int Total) EvaluateDetailed( IEnumerable rulesForShow, ResolverContextDTO context, IEnumerable userRoles) { var roles = userRoles.ToList(); var parsedRules = rulesForShow .Select(ParseExpression) .Where(p => p is not null) .Select(p => p!) .ToList(); int total = parsedRules.Count; if (total == 0) return (0, 0); int matched = parsedRules.Count(rule => EvaluateSingleRule(rule, context, roles)); return (matched, total); } private bool EvaluateSingleRule(ParsedRule rule, ResolverContextDTO context, List roles) { if (rule.Key is null || rule.Value is null) return false; if (rule.Key.Equals("role", StringComparison.OrdinalIgnoreCase)) return roles.Any(r => EvaluateOperator(rule.Operator, r, rule.Value)); if (rule.Key.Equals("roleid", StringComparison.OrdinalIgnoreCase)) { string? roleValue = ResolveContextValue(rule.Key, context); return roleValue is not null && EvaluateOperator(rule.Operator, roleValue, rule.Value); } string? contextValue = ResolveContextValue(rule.Key, context); return contextValue is not null && EvaluateOperator(rule.Operator, contextValue, rule.Value); } private static ParsedRule? ParseExpression(ShowRuleDTO rule) { if (string.IsNullOrWhiteSpace(rule.RuleKey)) return null; return new ParsedRule { ShowRuleId = rule.ShowRuleId, RuleCategory = rule.RuleCategory, Group = rule.RuleGroup, Key = rule.RuleKey, Operator = rule.RuleOperator, Value = rule.RuleValue }; } private bool EvaluateGroup( IEnumerable groupRules, ResolverContextDTO context, IEnumerable userRoles) { var roles = userRoles.ToList(); int moduleId = context.Ui?.ModuleId ?? -1; int menuId = context.Ui?.MenuId ?? -1; string action = context.Ui?.Action ?? string.Empty; string triggerType = context.Trigger?.Type ?? string.Empty; // ========================================================================= // MODULE CLICK: Only check moduleid rules // ========================================================================= if (moduleId != -1 && menuId == -1 && action == "ModuleNavigation") { var moduleRules = groupRules.Where(r => r.Key?.Equals("moduleid", StringComparison.OrdinalIgnoreCase) == true); if (moduleRules.Any()) { foreach (var rule in moduleRules) { if (rule.Key is null || rule.Value is null) continue; string? contextValue = ResolveContextValue(rule.Key, context); if (contextValue is not null && EvaluateOperator(rule.Operator, contextValue, rule.Value)) { _logger?.LogWarning("✅ Module Click - moduleid MATCHED!"); return true; } } return false; } } // ========================================================================= // NORMAL EVALUATION: Check ALL rules in the group (OR logic) // ========================================================================= return groupRules.Any(rule => { if (rule.Key is null || rule.Value is null) return false; // Role evaluation if (rule.Key.Equals("role", StringComparison.OrdinalIgnoreCase)) { return roles.Any(r => EvaluateOperator(rule.Operator, r, rule.Value)); } if (rule.Key.Equals("roleid", StringComparison.OrdinalIgnoreCase)) { string? roleValue = ResolveContextValue(rule.Key, context); return roleValue is not null && EvaluateOperator(rule.Operator, roleValue, rule.Value); } // ========================================================================= // TRIGGERTYPE EVALUATION - FIX: Skip MenuNavigation for ModuleNavigation // ========================================================================= if (rule.Key.Equals("triggertype", StringComparison.OrdinalIgnoreCase)) { string? contextValue = ResolveContextValue(rule.Key, context); string ruleValue = rule.Value ?? string.Empty; // If rule is MenuNavigation but request is ModuleNavigation, skip it if (ruleValue.Equals("MenuNavigation", StringComparison.OrdinalIgnoreCase)) { if (triggerType.Equals("ModuleNavigation", StringComparison.OrdinalIgnoreCase)) { _logger?.LogWarning("CheckOtherRules: Skipping MenuNavigation because request is ModuleNavigation"); return false; } } bool result = contextValue is not null && EvaluateOperator(rule.Operator, contextValue, rule.Value); _logger?.LogWarning("CheckOtherRules: triggertype={ContextValue} vs RuleValue={RuleValue} Result={Result}", contextValue, rule.Value, result); return result; } // Menu evaluation - skip if MenuId = -1 if (rule.Key.Equals("menuid", StringComparison.OrdinalIgnoreCase)) { int menuIdCheck = context.Ui?.MenuId ?? -1; if (menuIdCheck == -1) return false; string? contextValue = ResolveContextValue(rule.Key, context); return contextValue is not null && EvaluateOperator(rule.Operator, contextValue, rule.Value); } // Other rules string? contextValueFinal = ResolveContextValue(rule.Key, context); return contextValueFinal is not null && EvaluateOperator(rule.Operator, contextValueFinal, rule.Value); }); } private static bool EvaluateOperator(int op, string contextValue, string ruleValue) { if (string.IsNullOrEmpty(contextValue) || string.IsNullOrEmpty(ruleValue)) return false; string ctx = contextValue.Trim(); string rule = ruleValue.Trim(); return op switch { EQUALS => ctx.Equals(rule, StringComparison.OrdinalIgnoreCase), IN => rule.Split(',', StringSplitOptions.TrimEntries) .Any(v => ctx.Equals(v, StringComparison.OrdinalIgnoreCase)), NOT_EQUALS => !ctx.Equals(rule, StringComparison.OrdinalIgnoreCase), STARTS_WITH => ctx.StartsWith(rule, StringComparison.OrdinalIgnoreCase), _ => false }; } private string? ResolveContextValue(string key, ResolverContextDTO context) { if (context is null) return null; string keyLower = key.ToLowerInvariant(); if (keyLower == "moduleid") return context.Ui?.ModuleId.ToString(); if (keyLower == "menuid") return context.Ui?.MenuId.ToString(); if (keyLower == "action") return context.Ui?.Action; if (keyLower == "role") return context.User?.Role; if (keyLower == "roleid") { // Real GB5 RoleIds are commonly large negative numbers (see // feedback_gb5_negative_autonumber_ids memory) — "> 0" wrongly treated any // such RoleId as unset and fell through to the (usually empty) Role name, // silently failing every roleid-based audience rule on this system. -1 is // the actual "not set" sentinel. int? roleId = context.User?.RoleId; return roleId.HasValue && roleId.Value != -1 ? roleId.Value.ToString() : context.User?.Role; } // Was missing entirely — any "ouid" rule (e.g. AutoSubscribeRule=ByOU) silently // never matched, since ResolveContextValue returned null for it unconditionally. // Same negative-ID sentinel fix as "roleid" above. if (keyLower == "ouid") { int? ouId = context.User?.OuId; return ouId.HasValue && ouId.Value != -1 ? ouId.Value.ToString() : null; } if (keyLower == "triggertype") return context.Trigger?.Type; return null; } private sealed class ParsedRule { public int ShowRuleId { get; init; } public byte RuleCategory { get; init; } public int Group { get; init; } public string? Key { get; init; } public int Operator { get; init; } public string? Value { get; init; } } }