using System.Text.Json; using ECPBLL.Collaboration.Interfaces; using ECPBLL.Correspondence.Interfaces; using ECPDAL.Collaboration.DTOs; using ECPDAL.Collaboration.Interfaces; using GB5Shared.DaprCache; using GB5Shared.DTO.Framework.Criteria; using GB5Shared.DTO.Framework.Login; using GB5Shared.EventLogPublish; using GB5Shared.GB5Constant; using GB5Shared.GenerateAutoNumber; using GB5Shared.Resource.Response; using GB5Shared.Telemetry; using Microsoft.Extensions.Logging; namespace ECPBLL.Collaboration.Implementations; public class CollaborationSpaceBLL( ICollaborationSpaceDAL _dal, ICorrespondenceBLL _correspondenceBll, AutoNumber _autoNumber, KeyInvalidate _keyInvalidate, EventLogPublish _eventLog, ILogger _logger ) : ICollaborationSpaceBLL { // PropertyNamingPolicy left at its default (preserve declared PascalCase names) -- see // CommunicationBLL/Implementations/CommentThreadBLL.cs for why this override was removed. private static readonly JsonSerializerOptions JsonOpts = new(); // ── GET ─────────────────────────────────────────────────────────── public async Task GetCollaborationSpaceAsync(int collaborationSpaceId, LoginDTO login, CancellationToken ct) { GB5Trace.Step("get-collaboration-space", new { collaborationSpaceId }); var space = await _dal.GetCollaborationSpaceAsync(collaborationSpaceId, login, ct).ConfigureAwait(false); if (space is null) throw new InvalidOperationException($"Collaboration space {collaborationSpaceId} not found."); space.Members = await _dal.GetMembersForSpaceAsync(collaborationSpaceId, login, ct).ConfigureAwait(false); return JsonSerializer.Serialize(space, JsonOpts); } public async Task GetSelectListCollaborationSpaceAsync(LoginDTO login, CancellationToken ct) { GB5Trace.Step("get-selectlist-collaboration-space", new { login.UserId }); var list = await _dal.GetSelectListCollaborationSpaceAsync(login.UserId, login, ct).ConfigureAwait(false); return JsonSerializer.Serialize(list, JsonOpts); } public async Task GetCollaborationSpaceListAsync(LoginDTO login, CancellationToken ct) { GB5Trace.Step("get-collaboration-space-list", new { login.UserId }); var owned = await _dal.GetCollaborationSpaceListByOwnerAsync(login.UserId, login, ct).ConfigureAwait(false); var memberOf = await _dal.GetCollaborationSpaceListByMemberAsync(login.UserId, login, ct).ConfigureAwait(false); var combined = owned .Concat(memberOf) .GroupBy(s => s.CollaborationSpaceId) .Select(g => g.First()) .OrderByDescending(s => s.CreatedOn) .ToList(); return JsonSerializer.Serialize(combined, JsonOpts); } // ── SAVE (create or update) ──────────────────────────────────────── public async Task SaveCollaborationSpaceAsync(SaveCollaborationSpaceDTO dto, LoginDTO login, CancellationToken ct) { GB5Trace.Step("validate-collaboration-space", new { dto.CollaborationSpaceId }); if (string.IsNullOrWhiteSpace(dto.Name)) throw new InvalidOperationException("Name is required."); try { var isNew = dto.CollaborationSpaceId == 0; if (isNew) { var autoNumber = await _autoNumber.GetNumberAsync(1, "COLLABORATIONSPACE", login).ConfigureAwait(false); var newId = autoNumber.StartNumber; var entity = new CollaborationSpaceDTO { CollaborationSpaceId = newId, Name = dto.Name, Description = dto.Description, OwnerUserId = login.UserId, Status = CollaborationConstants.SpaceStatus.Active, OuId = dto.OuId, TenantId = login.ClientId, DatabaseName = login.DatabaseName, DatabaseType = login.DatabaseType, Version = 0, CreatedById = login.UserId, CreatedOn = DateTime.UtcNow, ModifiedById = login.UserId, ModifiedOn = DateTime.UtcNow, }; GB5Trace.Step("save-collaboration-space", new { newId, isNew }); await _dal.SaveCollaborationSpaceAsync(entity, login, ct).ConfigureAwait(false); // The creator is always the space's first member, with the Owner role. var memberAutoNumber = await _autoNumber.GetNumberAsync(1, "COLLABORATIONSPACEMEMBER", login).ConfigureAwait(false); await _dal.AddMemberAsync(new CollaborationSpaceMemberDTO { CollaborationSpaceMemberId = memberAutoNumber.StartNumber, CollaborationSpaceId = newId, UserId = login.UserId, Role = CollaborationConstants.MemberRole.Owner, TenantId = login.ClientId, DatabaseName = login.DatabaseName, DatabaseType = login.DatabaseType, Status = CollaborationConstants.RowStatus.Active, CreatedById = login.UserId, CreatedOn = DateTime.UtcNow, ModifiedById = login.UserId, ModifiedOn = DateTime.UtcNow, }, login, ct).ConfigureAwait(false); GB5Trace.Step("event-publish", new { Constant.EventTypeConstant.COLLABORATIONSPACECREATEDEVENTTYPEID }); await _eventLog.PublishEventLogAsync( "Collaboration Space Created", entity, Constant.EventTypeConstant.COLLABORATIONSPACECREATEDEVENTTYPEID, newId, login, ct: ct) .ConfigureAwait(false); await InvalidateListCacheAsync(login).ConfigureAwait(false); return $"{SuccessResponse.SaveSuccessMessage} {newId}"; } else { var existing = await _dal.GetCollaborationSpaceAsync(dto.CollaborationSpaceId, login, ct).ConfigureAwait(false); if (existing is null) throw new InvalidOperationException($"Collaboration space {dto.CollaborationSpaceId} not found."); if (existing.OwnerUserId != login.UserId) throw new UnauthorizedAccessException("Only the owner can update this collaboration space."); existing.Name = dto.Name; existing.Description = dto.Description; existing.OuId = dto.OuId; existing.ModifiedById = login.UserId; existing.ModifiedOn = DateTime.UtcNow; GB5Trace.Step("save-collaboration-space", new { dto.CollaborationSpaceId, isNew }); await _dal.UpdateCollaborationSpaceAsync(existing, login, ct).ConfigureAwait(false); GB5Trace.Step("event-publish", new { Constant.EventTypeConstant.COLLABORATIONSPACEUPDATEDEVENTTYPEID }); await _eventLog.PublishEventLogAsync( "Collaboration Space Updated", existing, Constant.EventTypeConstant.COLLABORATIONSPACEUPDATEDEVENTTYPEID, dto.CollaborationSpaceId, login, ct: ct) .ConfigureAwait(false); await InvalidateListCacheAsync(login).ConfigureAwait(false); await InvalidateSpaceDetailCacheAsync(dto.CollaborationSpaceId, login).ConfigureAwait(false); return SuccessResponse.UpdateSuccess; } } catch (Exception ex) { GB5Trace.MarkFailed("save-collaboration-space-failed", ex); _logger.LogError(ex, "SaveCollaborationSpace failed for CollaborationSpaceId {CollaborationSpaceId}", dto.CollaborationSpaceId); throw; } } // ── DELETE (archive) ──────────────────────────────────────────────── public async Task DeleteCollaborationSpaceAsync(int collaborationSpaceId, LoginDTO login, CancellationToken ct) { GB5Trace.Step("delete-collaboration-space", new { collaborationSpaceId }); try { var space = await _dal.GetCollaborationSpaceAsync(collaborationSpaceId, login, ct).ConfigureAwait(false); if (space is null) throw new InvalidOperationException($"Collaboration space {collaborationSpaceId} not found."); if (space.OwnerUserId != login.UserId) throw new UnauthorizedAccessException("Only the owner can delete this collaboration space."); await _dal.ArchiveCollaborationSpaceAsync(collaborationSpaceId, login.UserId, login, ct).ConfigureAwait(false); GB5Trace.Step("event-publish", new { Constant.EventTypeConstant.COLLABORATIONSPACEDELETEDEVENTTYPEID }); await _eventLog.PublishEventLogAsync( "Collaboration Space Deleted", new { collaborationSpaceId }, Constant.EventTypeConstant.COLLABORATIONSPACEDELETEDEVENTTYPEID, collaborationSpaceId, login, ct: ct) .ConfigureAwait(false); await InvalidateListCacheAsync(login).ConfigureAwait(false); await InvalidateSpaceDetailCacheAsync(collaborationSpaceId, login).ConfigureAwait(false); return SuccessResponse.DeleteSuccessMessage; } catch (Exception ex) { GB5Trace.MarkFailed("delete-collaboration-space-failed", ex); _logger.LogError(ex, "DeleteCollaborationSpace failed for CollaborationSpaceId {CollaborationSpaceId}", collaborationSpaceId); throw; } } // ── MEMBERSHIP ──────────────────────────────────────────────────── public async Task AddCollaborationSpaceMemberAsync(AddCollaborationSpaceMemberDTO dto, LoginDTO login, CancellationToken ct) { GB5Trace.Step("validate-collaboration-space-member", new { dto.CollaborationSpaceId, dto.UserId }); if (dto.Role is not (CollaborationConstants.MemberRole.Owner or CollaborationConstants.MemberRole.Member)) throw new InvalidOperationException("Role must be 1 (owner) or 2 (member)."); try { var space = await _dal.GetCollaborationSpaceAsync(dto.CollaborationSpaceId, login, ct).ConfigureAwait(false); if (space is null) throw new InvalidOperationException($"Collaboration space {dto.CollaborationSpaceId} not found."); if (space.OwnerUserId != login.UserId) throw new UnauthorizedAccessException("Only the owner can add members to this collaboration space."); var existingMember = await _dal.GetMemberAsync(dto.CollaborationSpaceId, dto.UserId, login, ct).ConfigureAwait(false); if (existingMember is not null) throw new InvalidOperationException($"User {dto.UserId} is already a member of this collaboration space."); var autoNumber = await _autoNumber.GetNumberAsync(1, "COLLABORATIONSPACEMEMBER", login).ConfigureAwait(false); var entity = new CollaborationSpaceMemberDTO { CollaborationSpaceMemberId = autoNumber.StartNumber, CollaborationSpaceId = dto.CollaborationSpaceId, UserId = dto.UserId, Role = dto.Role, TenantId = login.ClientId, DatabaseName = login.DatabaseName, DatabaseType = login.DatabaseType, Status = CollaborationConstants.RowStatus.Active, CreatedById = login.UserId, CreatedOn = DateTime.UtcNow, ModifiedById = login.UserId, ModifiedOn = DateTime.UtcNow, }; GB5Trace.Step("save-collaboration-space-member", new { entity.CollaborationSpaceMemberId }); await _dal.AddMemberAsync(entity, login, ct).ConfigureAwait(false); GB5Trace.Step("event-publish", new { Constant.EventTypeConstant.COLLABORATIONSPACEMEMBERADDEDEVENTTYPEID }); await _eventLog.PublishEventLogAsync( "Collaboration Space Member Added", entity, Constant.EventTypeConstant.COLLABORATIONSPACEMEMBERADDEDEVENTTYPEID, dto.CollaborationSpaceId, login, ct: ct) .ConfigureAwait(false); await InvalidateMemberListCacheAsync(dto.CollaborationSpaceId, login).ConfigureAwait(false); await InvalidateSpaceDetailCacheAsync(dto.CollaborationSpaceId, login).ConfigureAwait(false); return SuccessResponse.SaveSuccess; } catch (Exception ex) { GB5Trace.MarkFailed("add-collaboration-space-member-failed", ex); _logger.LogError(ex, "AddCollaborationSpaceMember failed for CollaborationSpaceId {CollaborationSpaceId}", dto.CollaborationSpaceId); throw; } } public async Task RemoveCollaborationSpaceMemberAsync(int collaborationSpaceId, int userId, LoginDTO login, CancellationToken ct) { GB5Trace.Step("remove-collaboration-space-member", new { collaborationSpaceId, userId }); try { var space = await _dal.GetCollaborationSpaceAsync(collaborationSpaceId, login, ct).ConfigureAwait(false); if (space is null) throw new InvalidOperationException($"Collaboration space {collaborationSpaceId} not found."); var isSelfRemoval = userId == login.UserId; if (space.OwnerUserId != login.UserId && !isSelfRemoval) throw new UnauthorizedAccessException("Only the owner can remove other members from this collaboration space."); if (userId == space.OwnerUserId) throw new InvalidOperationException("The owner cannot be removed — archive the collaboration space instead."); await _dal.RemoveMemberAsync(collaborationSpaceId, userId, login, ct).ConfigureAwait(false); GB5Trace.Step("event-publish", new { Constant.EventTypeConstant.COLLABORATIONSPACEMEMBERREMOVEDEVENTTYPEID }); await _eventLog.PublishEventLogAsync( "Collaboration Space Member Removed", new { collaborationSpaceId, userId }, Constant.EventTypeConstant.COLLABORATIONSPACEMEMBERREMOVEDEVENTTYPEID, collaborationSpaceId, login, ct: ct) .ConfigureAwait(false); await InvalidateMemberDetailCacheAsync(collaborationSpaceId, userId, login).ConfigureAwait(false); await InvalidateMemberListCacheAsync(collaborationSpaceId, login).ConfigureAwait(false); await InvalidateSpaceDetailCacheAsync(collaborationSpaceId, login).ConfigureAwait(false); return SuccessResponse.DeleteSuccessMessage; } catch (Exception ex) { GB5Trace.MarkFailed("remove-collaboration-space-member-failed", ex); _logger.LogError(ex, "RemoveCollaborationSpaceMember failed for CollaborationSpaceId {CollaborationSpaceId}", collaborationSpaceId); throw; } } public async Task GetCollaborationSpaceMemberAsync(int collaborationSpaceId, int userId, LoginDTO login, CancellationToken ct) { GB5Trace.Step("get-collaboration-space-member", new { collaborationSpaceId, userId }); var member = await _dal.GetMemberAsync(collaborationSpaceId, userId, login, ct).ConfigureAwait(false); if (member is null) throw new InvalidOperationException($"User {userId} is not a member of collaboration space {collaborationSpaceId}."); return JsonSerializer.Serialize(member, JsonOpts); } public async Task GetCollaborationSpaceMemberListAsync(int collaborationSpaceId, LoginDTO login, CancellationToken ct) { GB5Trace.Step("get-collaboration-space-member-list", new { collaborationSpaceId }); var members = await _dal.GetMembersForSpaceAsync(collaborationSpaceId, login, ct).ConfigureAwait(false); return JsonSerializer.Serialize(members, JsonOpts); } public async Task GetSelectListCollaborationSpaceMemberAsync(int collaborationSpaceId, LoginDTO login, CancellationToken ct) { GB5Trace.Step("get-selectlist-collaboration-space-member", new { collaborationSpaceId }); var list = await _dal.GetSelectListMembersAsync(collaborationSpaceId, login, ct).ConfigureAwait(false); return JsonSerializer.Serialize(list, JsonOpts); } public async Task GetCollaborationSpaceCorrespondenceListAsync(int collaborationSpaceId, LoginDTO login, CancellationToken ct) { GB5Trace.Step("get-collaboration-space-correspondence-list", new { collaborationSpaceId }); var criteria = new CriteriaDTO { SectionCriteriaList = [ new SectionCriteriaDTO { OperationType = CriteriaDTO.SectionOperationType.And, AttributesCriteriaList = [ new AttributesCriteriaDTO { FieldName = "ObjectTypeId", OperationType = CriteriaDTO.OperationType.Equal, FieldValue = Constant.EntityConstant.OBJECTCOLLABORATIONSPACE, JoinType = CriteriaDTO.AttributeJoinOperationType.And, }, new AttributesCriteriaDTO { FieldName = "ObjectId", OperationType = CriteriaDTO.OperationType.Equal, FieldValue = collaborationSpaceId, }, ], }, ], }; // Correspondence tab shows every linked record, not a page at a time. CorrespondenceDAL // has no "unpaged" mode (pageSize < 1 falls back to its own default of 20), so request a // page large enough to cover realistic per-space correspondence volumes instead. var (json, _) = await _correspondenceBll.GetCorrespondenceListAsync(criteria, 1, 1000, login, ct).ConfigureAwait(false); return json; } // Clears GetCollaborationSpaceList / GetSelectListCollaborationSpace (both keyed by userId, // both under OBJECTCOLLABORATIONSPACELIST) — every space mutation needs this. private async Task InvalidateListCacheAsync(LoginDTO login) { var cacheKey = new CacheKeyGeneration().KeyGeneration( login.UserId.ToString(), Constant.EntityConstant.OBJECTCOLLABORATIONSPACELIST, Constant.CacheKeyLevel.USER_LEVEL, login); await _keyInvalidate.InvalidateCache(cacheKey).ConfigureAwait(false); } // Clears GetCollaborationSpace's own per-id cache — needed by SaveCollaborationSpace's update // path and DeleteCollaborationSpace, both of which mutate a specific existing space. private async Task InvalidateSpaceDetailCacheAsync(int collaborationSpaceId, LoginDTO login) { var cacheKey = new CacheKeyGeneration().KeyGeneration( collaborationSpaceId.ToString(), Constant.EntityConstant.OBJECTCOLLABORATIONSPACE, Constant.CacheKeyLevel.CLIENT_LEVEL, login); await _keyInvalidate.InvalidateCache(cacheKey).ConfigureAwait(false); } // Clears GetCollaborationSpaceMemberList + GetSelectListCollaborationSpaceMember (the "add // member" picker) — needed whenever the member roster changes (add/remove). private async Task InvalidateMemberListCacheAsync(int collaborationSpaceId, LoginDTO login) { var keyGen = new CacheKeyGeneration(); var listKey = keyGen.KeyGeneration( collaborationSpaceId.ToString(), Constant.EntityConstant.OBJECTCOLLABORATIONSPACEMEMBERLIST, Constant.CacheKeyLevel.CLIENT_LEVEL, login); await _keyInvalidate.InvalidateCache(listKey).ConfigureAwait(false); var selectListKey = keyGen.KeyGeneration( collaborationSpaceId.ToString(), Constant.EntityConstant.OBJECTCOLLABORATIONSPACEMEMBER, Constant.CacheKeyLevel.CLIENT_LEVEL, login); await _keyInvalidate.InvalidateCache(selectListKey).ConfigureAwait(false); } // Clears one member's own GetCollaborationSpaceMember row (keyed "{spaceId}:{userId}"). private async Task InvalidateMemberDetailCacheAsync(int collaborationSpaceId, int userId, LoginDTO login) { var cacheKey = new CacheKeyGeneration().KeyGeneration( $"{collaborationSpaceId}:{userId}", Constant.EntityConstant.OBJECTCOLLABORATIONSPACEMEMBER, Constant.CacheKeyLevel.CLIENT_LEVEL, login); await _keyInvalidate.InvalidateCache(cacheKey).ConfigureAwait(false); } }