using System.Collections.Concurrent; using PlotLine.Models; namespace PlotLine.Services; public interface IWritingWorkspacePresenceService { Task RegisterAsync(int userId, string connectionId, WritingWindowRegistration registration, WordCompanionPresenceStatus wordStatus); Task HeartbeatAsync(int userId, string connectionId, WritingWindowHeartbeat heartbeat, WordCompanionPresenceStatus wordStatus); Task DisconnectAsync(string connectionId, WordCompanionPresenceStatus? wordStatus); Task GetStatusAsync(int userId, WritingWorkspaceScope scope, WordCompanionPresenceStatus wordStatus); Task> MarkStaleOfflineAsync(Func> wordStatusForUser); Task GetConnectionIdForRoleAsync(int userId, WritingWorkspaceScope scope, string role); } public sealed class WritingWorkspacePresenceService : IWritingWorkspacePresenceService { private static readonly TimeSpan ActiveTimeout = TimeSpan.FromSeconds(45); private readonly ConcurrentDictionary windowsById = new(StringComparer.Ordinal); private readonly ConcurrentDictionary windowIdsByConnection = new(StringComparer.Ordinal); public Task RegisterAsync(int userId, string connectionId, WritingWindowRegistration registration, WordCompanionPresenceStatus wordStatus) { var now = DateTime.UtcNow; var windowId = Clean(registration.BrowserWindowID); if (windowId is null) { windowId = Guid.NewGuid().ToString("N"); } var record = windowsById.AddOrUpdate( WindowKey(userId, windowId), _ => new WindowRecord { BrowserWindowID = windowId, UserID = userId, ConnectedUtc = now }, (_, existing) => existing); Apply(record, connectionId, registration.ProjectID, registration.BookID, registration.CurrentRoute, registration.WritingRole, registration.DisplayLabel, now); windowIdsByConnection[connectionId] = WindowKey(userId, windowId); return Task.FromResult(BuildStatus(userId, new WritingWorkspaceScope { ProjectID = record.ProjectID, BookID = record.BookID }, wordStatus)); } public Task HeartbeatAsync(int userId, string connectionId, WritingWindowHeartbeat heartbeat, WordCompanionPresenceStatus wordStatus) { var registration = new WritingWindowRegistration { BrowserWindowID = heartbeat.BrowserWindowID, ProjectID = heartbeat.ProjectID, BookID = heartbeat.BookID, CurrentRoute = heartbeat.CurrentRoute, WritingRole = heartbeat.WritingRole, DisplayLabel = heartbeat.DisplayLabel }; return RegisterAsync(userId, connectionId, registration, wordStatus); } public Task DisconnectAsync(string connectionId, WordCompanionPresenceStatus? wordStatus) { if (!windowIdsByConnection.TryRemove(connectionId, out var key) || !windowsById.TryGetValue(key, out var record) || !string.Equals(record.ConnectionID, connectionId, StringComparison.Ordinal)) { return Task.FromResult(null); } record.IsActive = false; record.ConnectionID = string.Empty; var status = BuildStatus(record.UserID, new WritingWorkspaceScope { ProjectID = record.ProjectID, BookID = record.BookID }, wordStatus ?? new WordCompanionPresenceStatus { UserID = record.UserID }); return Task.FromResult(status); } public Task GetStatusAsync(int userId, WritingWorkspaceScope scope, WordCompanionPresenceStatus wordStatus) => Task.FromResult(BuildStatus(userId, scope, wordStatus)); public async Task> MarkStaleOfflineAsync(Func> wordStatusForUser) { var now = DateTime.UtcNow; var changedScopes = new Dictionary(StringComparer.Ordinal); foreach (var record in windowsById.Values) { if (!record.IsActive || now - record.LastSeenUtc <= ActiveTimeout) { continue; } record.IsActive = false; if (!string.IsNullOrWhiteSpace(record.ConnectionID)) { windowIdsByConnection.TryRemove(record.ConnectionID, out _); record.ConnectionID = string.Empty; } changedScopes[ScopeKey(record.UserID, record.ProjectID, record.BookID)] = (record.UserID, record.ProjectID, record.BookID); } var statuses = new List(); foreach (var scope in changedScopes.Values) { var wordStatus = await wordStatusForUser(scope.UserID); statuses.Add(BuildStatus(scope.UserID, new WritingWorkspaceScope { ProjectID = scope.ProjectID, BookID = scope.BookID }, wordStatus)); } return statuses; } public Task GetConnectionIdForRoleAsync(int userId, WritingWorkspaceScope scope, string role) { var normalRole = WritingWorkspaceRoles.Normalise(role); var now = DateTime.UtcNow; var record = ActiveWindows(userId, scope, now) .Where(window => string.Equals(window.WritingRole, normalRole, StringComparison.Ordinal)) .OrderByDescending(window => window.LastSeenUtc) .FirstOrDefault(); return Task.FromResult(record?.ConnectionID); } private static void Apply(WindowRecord record, string connectionId, int? projectId, int? bookId, string? route, string? role, string? displayLabel, DateTime now) { record.ConnectionID = connectionId; record.ProjectID = Positive(projectId); record.BookID = Positive(bookId); record.CurrentRoute = Clean(route) ?? "/"; record.WritingRole = WritingWorkspaceRoles.Normalise(role); record.DisplayLabel = Clean(displayLabel); record.IsActive = true; record.LastSeenUtc = now; } private WritingWorkspaceStatus BuildStatus(int userId, WritingWorkspaceScope scope, WordCompanionPresenceStatus wordStatus) { var now = DateTime.UtcNow; var windows = ActiveWindows(userId, scope, now) .Select(ToPresence) .OrderBy(window => window.WritingRole) .ThenByDescending(window => window.LastSeenUtc) .ToList(); return new WritingWorkspaceStatus { UserID = userId, ProjectID = Positive(scope.ProjectID) ?? Positive(wordStatus.LinkedProjectID), BookID = Positive(scope.BookID) ?? Positive(wordStatus.LinkedBookID), WordCompanionConnected = wordStatus.IsConnected, WordDocumentOpen = wordStatus.DocumentOpen, WordProjectID = wordStatus.LinkedProjectID, WordBookID = wordStatus.LinkedBookID, CurrentStoryPosition = wordStatus.CurrentStoryPosition, Windows = windows, Launcher = LatestRole(windows, WritingWorkspaceRoles.Launcher), Context = LatestRole(windows, WritingWorkspaceRoles.Context), Intent = LatestRole(windows, WritingWorkspaceRoles.Intent) }; } private IEnumerable ActiveWindows(int userId, WritingWorkspaceScope scope, DateTime now) { var projectId = Positive(scope.ProjectID); var bookId = Positive(scope.BookID); return windowsById.Values.Where(record => record.UserID == userId && record.IsActive && now - record.LastSeenUtc <= ActiveTimeout && (!projectId.HasValue || record.ProjectID == projectId) && (!bookId.HasValue || record.BookID == bookId)); } private static WritingWindowPresence? LatestRole(IReadOnlyList windows, string role) => windows .Where(window => string.Equals(window.WritingRole, role, StringComparison.Ordinal)) .OrderByDescending(window => window.LastSeenUtc) .FirstOrDefault(); private static WritingWindowPresence ToPresence(WindowRecord record) => new() { BrowserWindowID = record.BrowserWindowID, UserID = record.UserID, ProjectID = record.ProjectID, BookID = record.BookID, CurrentRoute = record.CurrentRoute, WritingRole = record.WritingRole, DisplayLabel = record.DisplayLabel, IsActive = record.IsActive, ConnectedUtc = record.ConnectedUtc, LastSeenUtc = record.LastSeenUtc }; private static string? Clean(string? value) { var cleaned = (value ?? string.Empty).Trim(); return string.IsNullOrWhiteSpace(cleaned) ? null : cleaned; } private static int? Positive(int? value) => value is > 0 ? value.Value : null; private static string WindowKey(int userId, string windowId) => $"{userId}:{windowId}"; private static string ScopeKey(int userId, int? projectId, int? bookId) => $"{userId}:{projectId ?? 0}:{bookId ?? 0}"; private sealed class WindowRecord { public string BrowserWindowID { get; init; } = string.Empty; public int UserID { get; init; } public string ConnectionID { get; set; } = string.Empty; public int? ProjectID { get; set; } public int? BookID { get; set; } public string CurrentRoute { get; set; } = "/"; public string WritingRole { get; set; } = WritingWorkspaceRoles.None; public string? DisplayLabel { get; set; } public bool IsActive { get; set; } public DateTime ConnectedUtc { get; init; } public DateTime LastSeenUtc { get; set; } } }