using Collaboration.Application.DTOs.Requests; using Collaboration.Application.DTOs.Responses; using Collaboration.Application.Interfaces; using Collaboration.Domain.Entities; using Collaboration.Domain.Enums; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Logging; namespace Collaboration.Application.Services; public class CollabSessionService : ICollabSessionService { private readonly ICollabSessionRepository _sessions; private readonly ICollabParticipantRepository _participants; private readonly ICollabInviteRepository _invites; private readonly ICollabConfigRepository _configs; private readonly IHubContext _hub; private readonly ICollabNotificationService _notifications; private readonly ILogger _logger; // Cursor colors pool for participant assignment private static readonly string[] CursorColors = [ "#E53E3E", "#DD6B20", "#D69E2E", "#38A169", "#3182CE", "#805AD5", "#D53F8C", "#00B5D8", "#319795", "#E53E3E" ]; public CollabSessionService( ICollabSessionRepository sessions, ICollabParticipantRepository participants, ICollabInviteRepository invites, ICollabConfigRepository configs, IHubContext hub, ICollabNotificationService notifications, ILogger logger) { _sessions = sessions; _participants = participants; _invites = invites; _configs = configs; _hub = hub; _notifications = notifications; _logger = logger; } public async Task CreateAsync( CreateSessionRequest req, Guid userId, Guid orgId, string displayName, CancellationToken ct) { var config = await _configs.GetOrCreateDefaultAsync(orgId, ct); int maxParticipants = Math.Min( req.MaxParticipants ?? config.MaxParticipantsDefault, config.MaxParticipantsAbsolute); var session = CollabSession.Create( title: req.Title, sessionType: req.SessionType, mode: req.Mode, createdBy: userId, orgId: orgId, maxParticipants: maxParticipants, contextModuleCode: req.ContextModuleCode, contextRecordId: req.ContextRecordId, contextRecordLabel: req.ContextRecordLabel, contextRouteUrl: req.ContextRouteUrl, scheduledAt: req.ScheduledAt, scheduledDurationMinutes: req.ScheduledDurationMinutes); // Host joins immediately var hostParticipant = CollabParticipant.Create( sessionId: session.SessionId, userId: userId, displayName: displayName, role: ParticipantRole.Host, cursorColor: CursorColors[0]); await _sessions.AddAsync(session, ct); // EF tracking — add participant via context // (In your real app, add to session aggregate or via participant repo depending on your unit of work setup) await AddParticipantToSession(session, hostParticipant, ct); _logger.LogInformation("Session {SessionId} ({Code}) created by {UserId}", session.SessionId, session.SessionCode, userId); return SessionResponse.From(session); } public async Task GetAsync(Guid sessionId, Guid orgId, CancellationToken ct) { var session = await _sessions.GetWithParticipantsAsync(sessionId, ct); if (session is null || session.OrgId != orgId) return null; return SessionResponse.From(session); } public async Task> GetActiveSessionsAsync(Guid userId, Guid orgId, CancellationToken ct) { var sessions = await _sessions.GetActiveByUserAsync(userId, orgId, ct); return sessions.Select(SessionSummaryResponse.From).ToList(); } public async Task> GetScheduledSessionsAsync(Guid userId, Guid orgId, CancellationToken ct) { var sessions = await _sessions.GetScheduledByUserAsync(userId, orgId, ct); return sessions.Select(SessionSummaryResponse.From).ToList(); } public async Task UpdateStatusAsync( Guid sessionId, UpdateSessionStatusRequest req, Guid userId, Guid orgId, CancellationToken ct) { var session = await GetAndValidateSessionAsync(sessionId, orgId, ct); ValidateIsHost(session, userId); switch (req.Action.ToLowerInvariant()) { case "start": session.Start(userId); break; case "pause": session.Pause(userId); break; case "resume": session.Resume(userId); break; case "end": session.End(userId); await NotifySessionEnded(session, "Host ended the session"); break; default: throw new ArgumentException($"Unknown action: {req.Action}"); } await _sessions.SaveChangesAsync(ct); return SessionResponse.From(session); } public async Task JoinAsync( Guid sessionId, Guid userId, Guid orgId, string displayName, CancellationToken ct) { var session = await GetAndValidateSessionAsync(sessionId, orgId, ct); return await JoinSessionInternalAsync(session, userId, orgId, displayName, null, ct); } public async Task JoinViaTokenAsync( string token, Guid userId, Guid orgId, string displayName, CancellationToken ct) { var invite = await _invites.GetByTokenAsync(token, ct); if (invite is null || !invite.IsValid()) throw new InvalidOperationException("Invite link is invalid or has expired."); if (invite.InvitedUserId.HasValue && invite.InvitedUserId != userId) throw new UnauthorizedAccessException("This invite was not issued to you."); var session = await GetAndValidateSessionAsync(invite.SessionId, orgId, ct); invite.Accept(); await _invites.SaveChangesAsync(ct); return await JoinSessionInternalAsync(session, userId, orgId, displayName, invite.OfferedRole, ct); } public async Task CreateLinkInviteAsync( Guid sessionId, CreateLinkInviteRequest req, Guid userId, Guid orgId, CancellationToken ct) { var session = await GetAndValidateSessionAsync(sessionId, orgId, ct); ValidateIsHostOrPresenter(session, userId); var config = await _configs.GetOrCreateDefaultAsync(orgId, ct); var expiryMinutes = req.ExpiryMinutes ?? config.InviteLinkExpiryMinutes; var invite = CollabSessionInvite.Create( sessionId, userId, req.OfferedRole, expiryMinutes, invitedUserId: null); await _invites.AddAsync(invite, ct); return MapInviteResponse(invite, session.Title, orgId); } public async Task SendUserInvitesAsync( Guid sessionId, InviteUsersRequest req, Guid userId, Guid orgId, CancellationToken ct) { var session = await GetAndValidateSessionAsync(sessionId, orgId, ct); ValidateIsHostOrPresenter(session, userId); var config = await _configs.GetOrCreateDefaultAsync(orgId, ct); var expiryMinutes = req.ExpiryMinutes ?? config.InviteLinkExpiryMinutes; foreach (var targetUserId in req.UserIds) { var invite = CollabSessionInvite.Create( sessionId, userId, req.OfferedRole, expiryMinutes, invitedUserId: targetUserId); await _invites.AddAsync(invite, ct); session.AddEvent(SessionEventType.InviteSent, userId, $"{{\"targetUserId\":\"{targetUserId}\"}}"); // Notify in real-time via hub await _notifications.SendInviteNotificationAsync(targetUserId, MapInviteResponse(invite, session.Title, orgId)); } await _sessions.SaveChangesAsync(ct); } public async Task> GetPendingInvitesAsync(Guid userId, CancellationToken ct) { var invites = await _invites.GetPendingByUserAsync(userId, ct); return invites.Select(i => MapInviteResponse(i, i.Session.Title, Guid.Empty)).ToList(); } public async Task RespondToInviteAsync( Guid inviteId, RespondToInviteRequest req, Guid userId, CancellationToken ct) { var invite = await _invites.GetByIdAsync(inviteId, ct); if (invite is null) throw new KeyNotFoundException("Invite not found."); if (invite.InvitedUserId != userId) throw new UnauthorizedAccessException(); if (req.Accept) invite.Accept(); else invite.Decline(); await _invites.SaveChangesAsync(ct); } public async Task GrantControlAsync( Guid sessionId, GrantControlRequest req, Guid requestingUserId, Guid orgId, CancellationToken ct) { var session = await _sessions.GetWithParticipantsAsync(sessionId, ct) ?? throw new KeyNotFoundException("Session not found."); ValidateIsHost(session, requestingUserId); var config = await _configs.GetOrCreateDefaultAsync(orgId, ct); // Revoke current holder var currentHolder = await _participants.GetCurrentControlHolderAsync(sessionId, ct); if (currentHolder is not null) { currentHolder.RevokeControl(); session.AddEvent(SessionEventType.ControlRevoked, requestingUserId, $"{{\"fromUserId\":\"{currentHolder.UserId}\"}}"); } // Grant to target var target = await _participants.GetActiveByUserAndSessionAsync(req.TargetUserId, sessionId, ct) ?? throw new KeyNotFoundException("Target participant not found in session."); target.GrantControl(); session.AddEvent(SessionEventType.ControlGranted, requestingUserId, $"{{\"toUserId\":\"{req.TargetUserId}\"}}"); await _participants.SaveChangesAsync(ct); // Notify all participants via hub await _hub.Clients.Group($"session:{sessionId}") .ControlGranted(req.TargetUserId, target.DisplayName); } public async Task RevokeControlAsync(Guid sessionId, Guid requestingUserId, Guid orgId, CancellationToken ct) { var session = await _sessions.GetWithParticipantsAsync(sessionId, ct) ?? throw new KeyNotFoundException("Session not found."); ValidateIsHost(session, requestingUserId); var currentHolder = await _participants.GetCurrentControlHolderAsync(sessionId, ct); if (currentHolder is null) return; // No-op currentHolder.RevokeControl(); session.AddEvent(SessionEventType.ControlRevoked, requestingUserId, $"{{\"fromUserId\":\"{currentHolder.UserId}\"}}"); await _participants.SaveChangesAsync(ct); await _hub.Clients.Group($"session:{sessionId}") .ControlRevoked(currentHolder.UserId); } // ── Private helpers ─────────────────────────────────────────────────────── private async Task JoinSessionInternalAsync( CollabSession session, Guid userId, Guid orgId, string displayName, ParticipantRole? offeredRole, CancellationToken ct) { if (session.Status != SessionStatus.Active) throw new InvalidOperationException($"Session is not active (status: {session.Status})."); var activeParticipants = await _participants.GetActiveBySessionAsync(session.SessionId, ct); if (activeParticipants.Count >= session.MaxParticipants) throw new InvalidOperationException("Session is at maximum capacity."); // Check if already joined var existing = activeParticipants.FirstOrDefault(p => p.UserId == userId); if (existing is not null) { // Reconnect scenario — update connection handled in hub var config2 = await _configs.GetOrCreateDefaultAsync(orgId, ct); return BuildJoinResponse(session, existing, activeParticipants, config2); } var role = offeredRole ?? ParticipantRole.Viewer; var colorIndex = activeParticipants.Count % CursorColors.Length; var participant = CollabParticipant.Create( session.SessionId, userId, displayName, role, CursorColors[colorIndex]); await AddParticipantToSession(session, participant, ct); session.AddEvent(SessionEventType.ParticipantJoined, userId); await _sessions.SaveChangesAsync(ct); // Notify others await _hub.Clients.Group($"session:{session.SessionId}") .ParticipantJoined(ParticipantResponse.From(participant)); var config = await _configs.GetOrCreateDefaultAsync(orgId, ct); activeParticipants.Add(participant); _logger.LogInformation("User {UserId} joined session {SessionId} as {Role}", userId, session.SessionId, role); return BuildJoinResponse(session, participant, activeParticipants, config); } private static JoinSessionResponse BuildJoinResponse( CollabSession session, CollabParticipant self, List allParticipants, CollabConfig config) => new( session.SessionId, session.SessionCode, session.Title, session.Mode, session.ContextRouteUrl, self.Role, self.HasControl, self.CursorColor, allParticipants.Select(ParticipantResponse.From).ToList(), CollabConfigClientResponse.From(config) ); private async Task GetAndValidateSessionAsync(Guid sessionId, Guid orgId, CancellationToken ct) { var session = await _sessions.GetWithParticipantsAsync(sessionId, ct) ?? throw new KeyNotFoundException($"Session {sessionId} not found."); if (session.OrgId != orgId) throw new UnauthorizedAccessException("Session does not belong to your organization."); return session; } private static void ValidateIsHost(CollabSession session, Guid userId) { var isHost = session.Participants.Any(p => p.UserId == userId && p.Role == ParticipantRole.Host && p.IsActive); if (!isHost) throw new UnauthorizedAccessException("Only the session host can perform this action."); } private static void ValidateIsHostOrPresenter(CollabSession session, Guid userId) { var isAllowed = session.Participants.Any(p => p.UserId == userId && p.IsActive && (p.Role == ParticipantRole.Host || p.Role == ParticipantRole.Presenter)); if (!isAllowed) throw new UnauthorizedAccessException("Insufficient role for this action."); } private static InviteResponse MapInviteResponse(CollabSessionInvite invite, string sessionTitle, Guid orgId) => new( invite.InviteId, invite.SessionId, sessionTitle, invite.InviteToken, JoinUrl: $"/collab/join/{invite.InviteToken}", // Angular route invite.OfferedRole, invite.Status, invite.ExpiresAt, invite.CreatedAt ); private async Task AddParticipantToSession(CollabSession session, CollabParticipant participant, CancellationToken ct) { // In a real implementation this would go through your Unit of Work // For now, adding directly to DB context tracking await Task.CompletedTask; // Placeholder — wire to your UoW } private async Task NotifySessionEnded(CollabSession session, string reason) { await _hub.Clients.Group($"session:{session.SessionId}") .SessionEnded(reason); } }