using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using FrameworkBLL.Menu; using FrameworkDAL.CustomCode.MenuGroup; using GB5Shared.Tree; using FrameworkDAL.DTO.MenuGroup; using GB5Shared.DaprCache; using GB5Shared.DTO.Framework.HybridCache; using GB5Shared.DTO.Framework.Login; using GB5Shared.GenerateAutoNumber; using GB5Shared.QueryExecutor; using GB5Shared.Resource.Response; using GB5Shared.Telemetry; using Newtonsoft.Json; using static GB5Shared.GB5Constant.Constant; namespace FrameworkBLL.MenuGroup { public class MenuGroupBLL : IMenuGroupBLL { private readonly IMenuGroupDAL _menuGroupDAL; private readonly IMenuBLL _menuBLL; private readonly AutoNumber _autoNumber; private readonly IQueryExecutor _queryExecutor; private readonly KeyInvalidate _keyInvalidate; public MenuGroupBLL(IMenuGroupDAL menuGroupDAL, IMenuBLL menuBLL, AutoNumber autoNumber, IQueryExecutor queryExecutor, KeyInvalidate keyInvalidate) { _menuGroupDAL = menuGroupDAL; _menuBLL = menuBLL; _autoNumber = autoNumber; _queryExecutor = queryExecutor; _keyInvalidate = keyInvalidate; } public async Task GetMenuGroupTreeForSet(int menuSetId, LoginDTO loginDTO, CancellationToken ct) { var info = await _menuGroupDAL.GetMenuSetPresentationInfo(menuSetId, loginDTO, ct).ConfigureAwait(false); if (info is null) throw new ArgumentException($"MenuSet {menuSetId} does not exist.", nameof(menuSetId)); // PRESENTATIONTYPE = 0: delegate straight to the existing MenuBLL code path, // unchanged — no MenuGroup code executes for legacy sets. This is the byte-for-byte // legacy-compatibility guarantee, enforced structurally rather than by trying to // make the new code reproduce identical output. if (info.PresentationType == 0) return await _menuBLL.GetMenuForUserModule(loginDTO.UserId, info.ModuleId, loginDTO).ConfigureAwait(false); return await BuildGroupTreeJson(menuSetId, info, roleId: null, loginDTO, ct).ConfigureAwait(false); } public async Task GetMenuTreeForUserModule(int userId, int moduleId, LoginDTO loginDTO, CancellationToken ct) { var menuSetId = await _menuGroupDAL.GetMenuSetIdForModule(loginDTO.ClientId, moduleId, loginDTO, ct).ConfigureAwait(false); if (menuSetId is null) return await _menuBLL.GetMenuForUserModule(userId, moduleId, loginDTO).ConfigureAwait(false); var info = await _menuGroupDAL.GetMenuSetPresentationInfo(menuSetId.Value, loginDTO, ct).ConfigureAwait(false); // No MenuSet row, or PRESENTATIONTYPE = 0: delegate straight to the existing MenuBLL // code path, unchanged — no MenuGroup code executes for legacy sets. This is the // byte-for-byte legacy-compatibility guarantee, enforced structurally rather than by // trying to make the new code reproduce identical output. if (info is null || info.PresentationType == 0) return await _menuBLL.GetMenuForUserModule(userId, moduleId, loginDTO).ConfigureAwait(false); return await BuildGroupTreeJson(menuSetId.Value, info, loginDTO.RoleId, loginDTO, ct).ConfigureAwait(false); } /// Shared MMENUGROUP walk + leaf resolution behind both public entry points. /// Takes the already-resolved presentation info (both callers have already fetched it /// to decide whether to delegate to legacy) to avoid a redundant lookup on the hot path. /// roleId is threaded straight into GetResolvedLeavesByGroupIds — see that method's /// remarks for the null-vs-populated contract. private async Task BuildGroupTreeJson(int menuSetId, MenuSetPresentationInfoDTO info, int? roleId, LoginDTO loginDTO, CancellationToken ct) { if (info.MenuGroupId == -1) throw new ArgumentException( $"MenuSet {menuSetId} has PresentationType {info.PresentationType} but MenuGroupId is still the -1 default (no root configured).", nameof(menuSetId)); var flat = await _menuGroupDAL.GetMenuGroupFlatList(menuSetId, loginDTO, ct).ConfigureAwait(false); // -1 -> null Parent translation, mirroring MenuBLL's existing MMENU ParentId handling, // so TreeHelper.ConvertToTree (which treats Parent == null as "root") works unmodified. foreach (var node in flat) node.Parent = node.ParentGroupId == -1 ? null : new MenuGroupDTO { MenuGroupId = node.ParentGroupId }; var forest = flat.ConvertToTree(); var root = forest.FindTreeNode(info.MenuGroupId); if (root is null) throw new ArgumentException( $"MenuGroup {info.MenuGroupId} (MenuSet {menuSetId}'s configured root) was not found among the groups visible to this set.", nameof(menuSetId)); var allNodes = root.DepthFirstTraversal().ToList(); var groupIds = allNodes.Select(g => g.MenuGroupId).ToList(); var leaves = await _menuGroupDAL.GetResolvedLeavesByGroupIds(groupIds, menuSetId, roleId, loginDTO, ct).ConfigureAwait(false); var leavesByGroup = leaves.GroupBy(l => l.MenuGroupId).ToDictionary(g => g.Key, g => g.ToList()); foreach (var node in allNodes) node.Leaves = leavesByGroup.TryGetValue(node.MenuGroupId, out var l) ? l : new List(); return JsonConvert.SerializeObject(root); } public async Task SaveMenuGroup(MenuGroupDTO dto, LoginDTO loginDTO, CancellationToken ct) { if (dto is null) throw new ArgumentNullException(nameof(dto)); if (string.IsNullOrWhiteSpace(dto.GroupCode)) throw new ArgumentException("GroupCode is required.", nameof(dto)); if (string.IsNullOrWhiteSpace(dto.GroupName)) throw new ArgumentException("GroupName is required.", nameof(dto)); var tx = await _queryExecutor.BeginTransactionAsync(loginDTO).ConfigureAwait(false); try { bool isNew = dto.MenuGroupId <= 0; GB5Trace.Step("save-menugroup", new { dto.MenuGroupId, isNew }); var now = DateTime.UtcNow; dto.ModifiedById = loginDTO.UserId; dto.ModifiedOn = now; if (isNew) { var auto = await _autoNumber.GetNumberAsync(1, AUTONUMBERCONSTANT.MENUGROUP, loginDTO).ConfigureAwait(false); dto.MenuGroupId = auto.StartNumber; dto.CreatedById = loginDTO.UserId; dto.CreatedOn = now; await _menuGroupDAL.SaveMenuGroup(dto, loginDTO, tx, ct).ConfigureAwait(false); } else { await _menuGroupDAL.UpdateMenuGroup(dto, loginDTO, tx, ct).ConfigureAwait(false); } await _queryExecutor.CommitAsync(tx).ConfigureAwait(false); await InvalidateMenuGroupCache(dto.MenuSetId, loginDTO).ConfigureAwait(false); return isNew ? $"{SuccessResponse.SaveSuccessMessage} {dto.MenuGroupId}" : SuccessResponse.UpdateSuccess; } catch (Exception ex) { await _queryExecutor.RollbackAsync(tx).ConfigureAwait(false); GB5Trace.MarkFailed("save-menugroup-failed", ex); throw; } } public async Task DeleteMenuGroup(int menuGroupId, LoginDTO loginDTO, CancellationToken ct) { var existing = await _menuGroupDAL.GetMenuGroupById(menuGroupId, loginDTO, ct).ConfigureAwait(false); var tx = await _queryExecutor.BeginTransactionAsync(loginDTO).ConfigureAwait(false); try { GB5Trace.Step("delete-menugroup", new { menuGroupId }); await _menuGroupDAL.DeleteMenuGroup(menuGroupId, loginDTO, tx, ct).ConfigureAwait(false); await _queryExecutor.CommitAsync(tx).ConfigureAwait(false); await InvalidateMenuGroupCache(existing?.MenuSetId, loginDTO).ConfigureAwait(false); return SuccessResponse.DeleteSuccessMessage; } catch (Exception ex) { await _queryExecutor.RollbackAsync(tx).ConfigureAwait(false); GB5Trace.MarkFailed("delete-menugroup-failed", ex); throw; } } public async Task SaveMenuGroupDetail(int menuGroupId, List leaves, LoginDTO loginDTO, CancellationToken ct) { if (leaves is null) throw new ArgumentNullException(nameof(leaves)); var group = await _menuGroupDAL.GetMenuGroupById(menuGroupId, loginDTO, ct).ConfigureAwait(false); if (group is null) throw new ArgumentException($"MenuGroup {menuGroupId} does not exist.", nameof(menuGroupId)); var now = DateTime.UtcNow; int newCount = leaves.Count(l => l.MenuGroupDetailId <= 0); int nextNewId = 0; if (newCount > 0) { var auto = await _autoNumber.GetNumberAsync(newCount, AUTONUMBERCONSTANT.MENUGROUPDETAIL, loginDTO).ConfigureAwait(false); nextNewId = auto.StartNumber; } foreach (var leaf in leaves) { leaf.ModifiedById = loginDTO.UserId; leaf.ModifiedOn = now; if (leaf.MenuGroupDetailId <= 0) { leaf.MenuGroupDetailId = nextNewId++; leaf.CreatedById = loginDTO.UserId; leaf.CreatedOn = now; } } try { GB5Trace.Step("save-menugroupdetail", new { menuGroupId, count = leaves.Count }); await _menuGroupDAL.SaveMenuGroupDetail(menuGroupId, leaves, loginDTO, ct).ConfigureAwait(false); await InvalidateMenuGroupCache(group.MenuSetId, loginDTO).ConfigureAwait(false); return SuccessResponse.UpdateSuccess; } catch (Exception ex) { GB5Trace.MarkFailed("save-menugroupdetail-failed", ex); throw; } } public async Task ValidateMenuGroupVisibility(int menuGroupId, int callerMenuSetId, LoginDTO loginDTO, CancellationToken ct) { var group = await _menuGroupDAL.GetMenuGroupById(menuGroupId, loginDTO, ct).ConfigureAwait(false); if (group is null) throw new ArgumentException($"MenuGroup {menuGroupId} does not exist.", nameof(menuGroupId)); if (group.MenuSetId is not null && group.MenuSetId != callerMenuSetId) throw new ArgumentException( $"MenuGroup {menuGroupId} is private to another MenuSet and is not assignable here.", nameof(menuGroupId)); } /// Private groups (MenuSetId set) invalidate exactly one key. Shared bundle /// groups (MenuSetId null) can affect every MenuSet whose tree includes them — cheaply /// enumerating "every affected set" isn't worth building for this phase, so a shared-node /// write falls back to KeyInvalidate's documented "-1" delete-all sentinel instead of /// leaving other tenants' caches silently stale. private async Task InvalidateMenuGroupCache(int? menuSetId, LoginDTO loginDTO) { var cacheKey = menuSetId is int msid ? CacheKeyGenerator.KeyGeneration(msid, EntityConstant.OBJECTMENUGROUP, CacheKeyLevel.CLIENT_LEVEL, string.Empty, loginDTO) : "-1"; await _keyInvalidate.AllInvalidateCache(cacheKey).ConfigureAwait(false); } } }