using System.Text.Json; using CollabDAL.CustomCode.CollabConfig; using CollabDAL.CustomCode.CollabEvent; using CollabDAL.CustomCode.CollabInvite; using CollabDAL.CustomCode.CollabParticipant; using CollabDAL.CustomCode.CollabSession; using CollabDAL.DTO.CollabConfig; using CollabDAL.DTO.CollabEvent; using CollabDAL.DTO.CollabInvite; using CollabDAL.DTO.CollabParticipant; using CollabDAL.DTO.CollabSession; using GB5Shared.DaprCache; using GB5Shared.DTO.Framework.Login; using GB5Shared.EventLogPublish; using GB5Shared.GB5Constant; using GB5Shared.Validation; using Microsoft.Extensions.Logging; using Newtonsoft.Json; namespace CollabBLL.CollabSession; public class CollabSessionBLL( ICollabSessionDAL _sessionDal, ICollabParticipantDAL _participantDal, ICollabInviteDAL _inviteDal, ICollabEventDAL _eventDal, ICollabConfigDAL _configDal, IValidation _validation, KeyInvalidate _keyInvalidate, EventLogPublish _eventLog, ILogger _logger ) : ICollabSessionBLL { // ── CREATE SESSION ──────────────────────────────────────────────── public async Task CreateSessionAsync( CreateSessionSaveDTO dto, LoginDTO login, CancellationToken ct) { await _validation.NotEmpty(dto.Title, nameof(dto.Title)); await _validation.NotEmpty(dto.SessionType, nameof(dto.SessionType)); await _validation.NotEmpty(dto.Mode, nameof(dto.Mode)); if (dto.ScheduledAt.HasValue && dto.ScheduledAt.Value <= DateTime.UtcNow) throw new InvalidOperationException("Scheduled time must be in the future."); var sessionCode = CollabConstants.GenerateSessionCode(); var sessionId = await _sessionDal .CreateSessionAsync(dto, sessionCode, login, ct).ConfigureAwait(false); // Auto-join creator as Host with control await _participantDal.AddParticipantAsync(new CollabParticipantDTO { SessionId = sessionId, UserId = login.UserId.ToString(), DisplayName = login.UserName, Role = CollabConstants.Roles.Host, HasControl = true, CursorColor = CollabConstants.CursorColors[0], DatabaseName = login.DatabaseName, }, login, ct).ConfigureAwait(false); await _eventDal.InsertEventAsync(new CollabSessionEventDTO { SessionId = sessionId, EventType = CollabConstants.EventTypes.SessionCreated, TriggeredBy = login.UserCode, DatabaseName = login.DatabaseName, }, login, ct).ConfigureAwait(false); await _eventLog.PublishEventLogAsync( "Collaboration Session Created", new { dto.Title, dto.Mode, SessionCode = sessionCode }, Constant.EventTypeConstant.SAVEDOCUMENTSET, 0, login).ConfigureAwait(false); var cacheKey = new CacheKeyGeneration().KeyGeneration( sessionId.ToString(), Constant.EntityConstant.OBJECTCOLLABSESSIONLIST, Constant.CacheKeyLevel.CLIENT_LEVEL, login); await _keyInvalidate.InvalidateCache(cacheKey).ConfigureAwait(false); var session = await _sessionDal.GetSessionByIdAsync(sessionId, login, ct).ConfigureAwait(false); return JsonConvert.SerializeObject(session); } // ── GET SESSION ─────────────────────────────────────────────────── public async Task GetSessionAsync(Guid sessionId, LoginDTO login, CancellationToken ct) { var session = await _sessionDal .GetSessionByIdAsync(sessionId, login, ct).ConfigureAwait(false); if (session is null) throw new InvalidOperationException($"Session {sessionId} not found."); session.Participants = await _participantDal .GetParticipantsBySessionAsync(sessionId, login, ct).ConfigureAwait(false); return JsonConvert.SerializeObject(session); } // ── GET ACTIVE SESSIONS ─────────────────────────────────────────── public async Task GetActiveSessionsAsync(LoginDTO login, CancellationToken ct) { var sessions = await _sessionDal.GetActiveSessionsAsync(login, ct).ConfigureAwait(false); return JsonConvert.SerializeObject(sessions); } // ── GET SCHEDULED SESSIONS ──────────────────────────────────────── public async Task GetScheduledSessionsAsync(LoginDTO login, CancellationToken ct) { var sessions = await _sessionDal.GetScheduledSessionsAsync(login, ct).ConfigureAwait(false); return JsonConvert.SerializeObject(sessions); } // ── UPDATE SESSION STATUS ───────────────────────────────────────── public async Task UpdateSessionStatusAsync( UpdateSessionStatusSaveDTO dto, LoginDTO login, CancellationToken ct) { var session = await _sessionDal .GetSessionByIdAsync(dto.SessionId, login, ct).ConfigureAwait(false); if (session is null) throw new InvalidOperationException("Session not found."); var caller = await _participantDal .GetActiveParticipantAsync(dto.SessionId, login.UserId.ToString(), login, ct) .ConfigureAwait(false); if (caller?.Role != CollabConstants.Roles.Host) throw new InvalidOperationException("Only the Host can change session status."); var (valid, error) = ValidateStatusTransition(session.Status, dto.NewStatus); if (!valid) throw new InvalidOperationException(error!); await _sessionDal.UpdateSessionStatusAsync(dto, login, ct).ConfigureAwait(false); var eventType = dto.NewStatus switch { CollabConstants.Status.Active => CollabConstants.EventTypes.SessionStarted, CollabConstants.Status.Paused => CollabConstants.EventTypes.SessionPaused, _ => CollabConstants.EventTypes.SessionEnded, }; await _eventDal.InsertEventAsync(new CollabSessionEventDTO { SessionId = dto.SessionId, EventType = eventType, TriggeredBy = login.UserCode, DatabaseName = login.DatabaseName, }, login, ct).ConfigureAwait(false); await _eventLog.PublishEventLogAsync( $"Collaboration Session {dto.NewStatus}", new { dto.SessionId, dto.NewStatus }, Constant.EventTypeConstant.SAVEDOCUMENTSET, 0, login).ConfigureAwait(false); var cacheKey = new CacheKeyGeneration().KeyGeneration( dto.SessionId.ToString(), Constant.EntityConstant.OBJECTCOLLABSESSION, Constant.CacheKeyLevel.CLIENT_LEVEL, login); await _keyInvalidate.InvalidateCache(cacheKey).ConfigureAwait(false); return JsonConvert.SerializeObject(new { Success = true }); } // ── JOIN SESSION ────────────────────────────────────────────────── public async Task JoinSessionAsync(Guid sessionId, LoginDTO login, CancellationToken ct) { var session = await _sessionDal .GetSessionByIdAsync(sessionId, login, ct).ConfigureAwait(false); if (session is null) throw new InvalidOperationException("Session not found."); if (session.Status != CollabConstants.Status.Active) throw new InvalidOperationException("Session is not active."); var config = await _configDal.GetConfigAsync(login, ct).ConfigureAwait(false) ?? CollabConfigDTO.Default; var participants = await _participantDal .GetParticipantsBySessionAsync(sessionId, login, ct).ConfigureAwait(false); var existing = participants.FirstOrDefault(p => p.UserId == login.UserId.ToString()); if (existing is null) { if (participants.Count >= config.MaxParticipantsAbsolute) throw new InvalidOperationException("Session is full."); var usedColors = participants.Select(p => p.CursorColor).ToHashSet(); var color = CollabConstants.CursorColors .FirstOrDefault(c => !usedColors.Contains(c)) ?? CollabConstants.CursorColors[participants.Count % CollabConstants.CursorColors.Length]; await _participantDal.AddParticipantAsync(new CollabParticipantDTO { SessionId = sessionId, UserId = login.UserId.ToString(), DisplayName = login.UserName, Role = CollabConstants.Roles.Viewer, HasControl = false, CursorColor = color, DatabaseName = login.DatabaseName, }, login, ct).ConfigureAwait(false); } else { // Re-join: re-activate existing participant record await _participantDal.UpdateParticipantConnectionAsync( new UpdateParticipantConnectionSaveDTO { SessionId = sessionId, UserId = login.UserId.ToString(), ConnectionId = null, IsActive = true, }, login, ct).ConfigureAwait(false); } var updatedParticipants = await _participantDal .GetParticipantsBySessionAsync(sessionId, login, ct).ConfigureAwait(false); var me = updatedParticipants.First(p => p.UserId == login.UserId.ToString()); session.Participants = updatedParticipants; var joinResponse = new CollabJoinResponseDTO { Session = session, YourRole = me.Role, YouHaveControl = me.HasControl, YourCursorColor = me.CursorColor, Participants = updatedParticipants, Config = config.ToClientDTO(), }; await _eventDal.InsertEventAsync(new CollabSessionEventDTO { SessionId = sessionId, EventType = CollabConstants.EventTypes.ParticipantJoined, TriggeredBy = login.UserCode, DatabaseName = login.DatabaseName, }, login, ct).ConfigureAwait(false); return JsonConvert.SerializeObject(joinResponse); } // ── JOIN VIA TOKEN ──────────────────────────────────────────────── public async Task JoinViaTokenAsync(string token, LoginDTO login, CancellationToken ct) { var invite = await _inviteDal.GetInviteByTokenAsync(token, ct).ConfigureAwait(false); if (invite is null) throw new InvalidOperationException("Invite not found."); if (invite.Status != "Pending") throw new InvalidOperationException("This invite has already been used."); if (invite.ExpiresAt < DateTime.UtcNow) throw new InvalidOperationException("This invite has expired."); if (invite.InvitedUserId is not null && invite.InvitedUserId != login.UserId.ToString()) throw new InvalidOperationException("This invite is for a different user."); await _inviteDal.UpdateInviteStatusAsync(invite.InviteId, "Accepted", login, ct) .ConfigureAwait(false); await _eventDal.InsertEventAsync(new CollabSessionEventDTO { SessionId = invite.SessionId, EventType = CollabConstants.EventTypes.InviteAccepted, TriggeredBy = login.UserCode, DatabaseName = login.DatabaseName, }, login, ct).ConfigureAwait(false); return await JoinSessionAsync(invite.SessionId, login, ct).ConfigureAwait(false); } // ── GRANT CONTROL ───────────────────────────────────────────────── public async Task GrantControlAsync(GrantControlSaveDTO dto, LoginDTO login, CancellationToken ct) { var caller = await _participantDal .GetActiveParticipantAsync(dto.SessionId, login.UserId.ToString(), login, ct) .ConfigureAwait(false); if (caller?.Role != CollabConstants.Roles.Host) throw new InvalidOperationException("Only the Host can grant control."); await _participantDal.GrantControlAsync(dto, login, ct).ConfigureAwait(false); await _eventDal.InsertEventAsync(new CollabSessionEventDTO { SessionId = dto.SessionId, EventType = CollabConstants.EventTypes.ControlGranted, TriggeredBy = login.UserCode, Payload = System.Text.Json.JsonSerializer.Serialize(new { dto.TargetUserId }), DatabaseName = login.DatabaseName, }, login, ct).ConfigureAwait(false); var cacheKey = new CacheKeyGeneration().KeyGeneration( dto.SessionId.ToString(), Constant.EntityConstant.OBJECTCOLLABSESSION, Constant.CacheKeyLevel.CLIENT_LEVEL, login); await _keyInvalidate.InvalidateCache(cacheKey).ConfigureAwait(false); return JsonConvert.SerializeObject(new { Success = true }); } // ── REVOKE CONTROL ──────────────────────────────────────────────── public async Task RevokeControlAsync(Guid sessionId, LoginDTO login, CancellationToken ct) { var caller = await _participantDal .GetActiveParticipantAsync(sessionId, login.UserId.ToString(), login, ct) .ConfigureAwait(false); if (caller?.Role != CollabConstants.Roles.Host) throw new InvalidOperationException("Only the Host can revoke control."); await _participantDal.RevokeAllControlAsync(sessionId, login, ct).ConfigureAwait(false); // Re-grant control back to host await _participantDal.GrantControlAsync(new GrantControlSaveDTO { SessionId = sessionId, TargetUserId = login.UserId.ToString(), }, login, ct).ConfigureAwait(false); await _eventDal.InsertEventAsync(new CollabSessionEventDTO { SessionId = sessionId, EventType = CollabConstants.EventTypes.ControlRevoked, TriggeredBy = login.UserCode, DatabaseName = login.DatabaseName, }, login, ct).ConfigureAwait(false); var cacheKey = new CacheKeyGeneration().KeyGeneration( sessionId.ToString(), Constant.EntityConstant.OBJECTCOLLABSESSION, Constant.CacheKeyLevel.CLIENT_LEVEL, login); await _keyInvalidate.InvalidateCache(cacheKey).ConfigureAwait(false); return JsonConvert.SerializeObject(new { Success = true }); } // ── INVITE USERS ────────────────────────────────────────────────── public async Task InviteUsersAsync(InviteUsersSaveDTO dto, LoginDTO login, CancellationToken ct) { await _validation.NotEmpty(dto.OfferedRole, nameof(dto.OfferedRole)); if (dto.UserIds.Count == 0) throw new InvalidOperationException("At least one user must be specified."); var config = await _configDal.GetConfigAsync(login, ct).ConfigureAwait(false) ?? CollabConfigDTO.Default; var expiryMinutes = dto.ExpiryMinutes ?? config.InviteLinkExpiryMinutes; var invites = new List(); foreach (var userId in dto.UserIds) { var invite = new CollabSessionInviteDTO { SessionId = dto.SessionId, InvitedUserId = userId, OfferedRole = dto.OfferedRole, Status = "Pending", ExpiresAt = DateTime.UtcNow.AddMinutes(expiryMinutes), CreatedBy = login.UserCode, DatabaseName = login.DatabaseName, }; await _inviteDal.CreateInviteAsync(invite, login, ct).ConfigureAwait(false); invites.Add(invite); } await _eventDal.InsertEventAsync(new CollabSessionEventDTO { SessionId = dto.SessionId, EventType = CollabConstants.EventTypes.InviteSent, TriggeredBy = login.UserCode, Payload = System.Text.Json.JsonSerializer.Serialize(new { UserCount = dto.UserIds.Count }), DatabaseName = login.DatabaseName, }, login, ct).ConfigureAwait(false); return JsonConvert.SerializeObject(invites); } // ── CREATE LINK INVITE ──────────────────────────────────────────── public async Task CreateLinkInviteAsync( CreateLinkInviteSaveDTO dto, LoginDTO login, CancellationToken ct) { var config = await _configDal.GetConfigAsync(login, ct).ConfigureAwait(false) ?? CollabConfigDTO.Default; if (!config.AllowGuestJoinViaLink) throw new InvalidOperationException("Link invites are disabled for this organisation."); var expiryMinutes = dto.ExpiryMinutes ?? config.InviteLinkExpiryMinutes; var invite = new CollabSessionInviteDTO { SessionId = dto.SessionId, InvitedUserId = null, OfferedRole = dto.OfferedRole, Status = "Pending", ExpiresAt = DateTime.UtcNow.AddMinutes(expiryMinutes), CreatedBy = login.UserCode, DatabaseName = login.DatabaseName, }; var inviteId = await _inviteDal.CreateInviteAsync(invite, login, ct).ConfigureAwait(false); // JoinUrl constructed in BLL — not stored in DB invite.InviteId = inviteId; invite.JoinUrl = $"/collaboration/join/{invite.InviteToken}"; return JsonConvert.SerializeObject(invite); } // ── GET PENDING INVITES ─────────────────────────────────────────── public async Task GetPendingInvitesAsync(LoginDTO login, CancellationToken ct) { var invites = await _inviteDal .GetPendingInvitesForUserAsync(login.UserId.ToString(), login, ct).ConfigureAwait(false); return JsonConvert.SerializeObject(invites); } // ── RESPOND TO INVITE ───────────────────────────────────────────── public async Task RespondToInviteAsync( Guid inviteId, bool accept, LoginDTO login, CancellationToken ct) { var invite = await _inviteDal .GetInviteByTokenAsync(inviteId.ToString(), ct).ConfigureAwait(false); if (invite is null) throw new InvalidOperationException("Invite not found."); if (invite.Status != "Pending") throw new InvalidOperationException("This invite is no longer pending."); if (invite.ExpiresAt < DateTime.UtcNow) throw new InvalidOperationException("This invite has expired."); var newStatus = accept ? "Accepted" : "Declined"; await _inviteDal.UpdateInviteStatusAsync(inviteId, newStatus, login, ct) .ConfigureAwait(false); var eventType = accept ? CollabConstants.EventTypes.InviteAccepted : CollabConstants.EventTypes.InviteDeclined; await _eventDal.InsertEventAsync(new CollabSessionEventDTO { SessionId = invite.SessionId, EventType = eventType, TriggeredBy = login.UserCode, DatabaseName = login.DatabaseName, }, login, ct).ConfigureAwait(false); var cacheKey = new CacheKeyGeneration().KeyGeneration( login.UserId.ToString(), Constant.EntityConstant.OBJECTCOLLABINVITE, Constant.CacheKeyLevel.USER_LEVEL, login); await _keyInvalidate.InvalidateCache(cacheKey).ConfigureAwait(false); return JsonConvert.SerializeObject(new { Success = accept }); } // ── HUB OPERATIONS ──────────────────────────────────────────────── public async Task UpdateLastActivityAsync(Guid sessionId, LoginDTO login, CancellationToken ct) => await _sessionDal.UpdateLastActivityAsync(sessionId, login, ct).ConfigureAwait(false); public async Task RecordHubConnectionAsync( Guid sessionId, string userId, string connectionId, LoginDTO login, CancellationToken ct) { await _participantDal.UpdateParticipantConnectionAsync( new UpdateParticipantConnectionSaveDTO { SessionId = sessionId, UserId = userId, ConnectionId = connectionId, IsActive = true, }, login, ct).ConfigureAwait(false); } public async Task HandleHubDisconnectAsync( string connectionId, LoginDTO login, CancellationToken ct) { var participant = await _participantDal .GetParticipantByConnectionAsync(connectionId, login, ct) .ConfigureAwait(false); if (participant is null) return null; await _participantDal.UpdateParticipantLeftAsync( participant.SessionId, login.UserId.ToString(), login, ct) .ConfigureAwait(false); await _eventDal.InsertEventAsync(new CollabSessionEventDTO { SessionId = participant.SessionId, EventType = CollabConstants.EventTypes.ParticipantLeft, TriggeredBy = login.UserCode, DatabaseName = login.DatabaseName, }, login, ct).ConfigureAwait(false); return participant; } public async Task RecordHeartbeatAsync( Guid sessionId, string userId, LoginDTO login, CancellationToken ct) { await _participantDal.UpdateHeartbeatAsync(sessionId, userId, login, ct) .ConfigureAwait(false); await _sessionDal.UpdateLastActivityAsync(sessionId, login, ct) .ConfigureAwait(false); } public async Task GetControlHolderConnectionAsync( Guid sessionId, LoginDTO login, CancellationToken ct) { var holder = await _participantDal .GetControlHolderAsync(sessionId, login, ct) .ConfigureAwait(false); return holder?.ConnectionId; } // ── PRIVATE HELPERS ─────────────────────────────────────────────── private static (bool valid, string? error) ValidateStatusTransition( string current, string requested) { return (current, requested) switch { ("Pending", "Active") => (true, null), ("Active", "Paused") => (true, null), ("Active", "Ended") => (true, null), ("Paused", "Active") => (true, null), ("Paused", "Ended") => (true, null), _ => (false, $"Cannot transition from {current} to {requested}."), }; } }