diff --git a/src/backend/server/src/Todo.Api/Hubs/TodoHub.cs b/src/backend/server/src/Todo.Api/Hubs/TodoHub.cs index 2b8f982..d612959 100644 --- a/src/backend/server/src/Todo.Api/Hubs/TodoHub.cs +++ b/src/backend/server/src/Todo.Api/Hubs/TodoHub.cs @@ -1,107 +1,173 @@ +using System.Collections.Concurrent; +using System.Security.Claims; using System.Text.Json; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.SignalR; using Todo.Api.Hubs.Models; using Todo.Core.Interfaces.Persistence; -namespace Todo.Api.Hubs +namespace Todo.Api.Hubs; + +[Authorize] +public class TodoHub : Hub { - [Authorize] - public class TodoHub : Hub + private readonly ITodoRepository _todoRepository; + + private static readonly ConcurrentDictionary> ConnectedUsers = new(); + + public override Task OnConnectedAsync() { - private readonly ITodoRepository _todoRepository; + var userId = Context.User.FindFirstValue("sub"); - public TodoHub(ITodoRepository todoRepository) + // Try to get a List of existing user connections from the cache + ConnectedUsers.TryGetValue(userId, out var existingUserConnectionIds); + + // happens on the very first connection from the user + existingUserConnectionIds ??= new List(); + + // First add to a List of existing user connections (i.e. multiple web browser tabs) + existingUserConnectionIds.Add(Context.ConnectionId); + + + // Add to the global dictionary of connected users + ConnectedUsers.TryAdd(userId, existingUserConnectionIds); + + return base.OnConnectedAsync(); + } + + public override Task OnDisconnectedAsync(Exception? exception) + { + var userId = Context.User.FindFirstValue("sub"); + + ConnectedUsers.TryGetValue(userId, out var existingUserConnectionIds); + + // remove the connection id from the List + existingUserConnectionIds?.Remove(Context.ConnectionId); + + // If there are no connection ids in the List, delete the user from the global cache (ConnectedUsers). + if (existingUserConnectionIds?.Count == 0) { - _todoRepository = todoRepository; + // if there are no connections for the user, + // just delete the userName key from the ConnectedUsers concurent dictionary + ConnectedUsers.TryRemove(userId, out _); } - public async Task CreateTodo(string todoTitle, string projectName) - { - if (todoTitle is null) - throw new ArgumentException("title cannot be null"); - var _ = await _todoRepository.CreateTodoAsync(todoTitle, projectName); + return base.OnDisconnectedAsync(exception); + } - var todos = await _todoRepository.GetNotDoneTodos(); - var serializedTodos = - JsonSerializer.Serialize(todos - .Select(t => new TodoResponse { Id = t.Id, Title = t.Title, Project = t.Project }) - .ToList()); - await Clients.Caller.SendAsync("getInboxTodos", serializedTodos); - } + public TodoHub(ITodoRepository todoRepository) + { + _todoRepository = todoRepository; + } - public async Task UpdateTodo(string todoId, bool todoStatus) - { - await _todoRepository.UpdateTodoStatus(todoId, todoStatus); + public async Task CreateTodo(string todoTitle, string projectName) + { + if (todoTitle is null) + throw new ArgumentException("title cannot be null"); + var _ = await _todoRepository.CreateTodoAsync(todoTitle, projectName); - var todos = await _todoRepository.GetNotDoneTodos(); - var serializedTodos = - JsonSerializer.Serialize(todos - .Select(t => new TodoResponse - { Id = t.Id, Title = t.Title, Status = t.Status, Project = t.Project }) - .ToList()); - - await Clients.Caller.SendAsync("getInboxTodos", serializedTodos); - } - - public async Task GetTodos() - { - var todos = await _todoRepository.GetTodosAsync(); - var serializedTodos = JsonSerializer.Serialize(todos - .Select(t => new TodoResponse { Id = t.Id, Title = t.Title, Status = t.Status, Project = t.Project }) + var todos = await _todoRepository.GetNotDoneTodos(); + var serializedTodos = + JsonSerializer.Serialize(todos + .Select(t => new TodoResponse { Id = t.Id, Title = t.Title, Project = t.Project }) .ToList()); - await Clients.Caller.SendAsync("todos", serializedTodos); - } + await RunOnUserConnections(async (connections) => + await Clients.Clients(connections).SendAsync("getInboxTodos", serializedTodos)); + } - public async Task GetInboxTodos() - { - var todos = await _todoRepository.GetNotDoneTodos(); - var serializedTodos = JsonSerializer.Serialize(todos - .Select(t => new TodoResponse { Id = t.Id, Title = t.Title, Status = t.Status, Project = t.Project }) + + public async Task UpdateTodo(string todoId, bool todoStatus) + { + await _todoRepository.UpdateTodoStatus(todoId, todoStatus); + + var todos = await _todoRepository.GetNotDoneTodos(); + var serializedTodos = + JsonSerializer.Serialize(todos + .Select(t => new TodoResponse + { Id = t.Id, Title = t.Title, Status = t.Status, Project = t.Project }) .ToList()); - await Clients.Caller.SendAsync("getInboxTodos", serializedTodos); - } + await RunOnUserConnections(async (connections) => + await Clients.Clients(connections).SendAsync("getInboxTodos", serializedTodos)); + } - public async Task GetTodo(string todoId) + public async Task GetTodos() + { + var todos = await _todoRepository.GetTodosAsync(); + var serializedTodos = JsonSerializer.Serialize(todos + .Select(t => new TodoResponse { Id = t.Id, Title = t.Title, Status = t.Status, Project = t.Project }) + .ToList()); + + await RunOnUserConnections(async (connections) => + await Clients.Clients(connections).SendAsync("todos", serializedTodos)); + } + + public async Task GetInboxTodos() + { + var todos = await _todoRepository.GetNotDoneTodos(); + var serializedTodos = JsonSerializer.Serialize(todos + .Select(t => new TodoResponse { Id = t.Id, Title = t.Title, Status = t.Status, Project = t.Project }) + .ToList()); + + await RunOnUserConnections(async (connections) => + await Clients.Clients(connections).SendAsync("getInboxTodos", serializedTodos)); + } + + public async Task GetTodo(string todoId) + { + var todo = await _todoRepository.GetTodoByIdAsync(todoId); + var serializedTodo = JsonSerializer.Serialize(new TodoResponse() { - var todo = await _todoRepository.GetTodoByIdAsync(todoId); - var serializedTodo = JsonSerializer.Serialize(new TodoResponse() - { - Id = todo.Id, - Project = todo.Project, - Status = todo.Status, - Title = todo.Title, - }); + Id = todo.Id, + Project = todo.Project, + Status = todo.Status, + Title = todo.Title, + }); - await Clients.Caller.SendAsync("getTodo", serializedTodo); - } + await RunOnUserConnections(async (connections) => + await Clients.Clients(connections).SendAsync("getTodo", serializedTodo)); + } - public async Task ReplaceTodo(string updateTodoRequest) + public async Task ReplaceTodo(string updateTodoRequest) + { + var updateTodo = JsonSerializer.Deserialize(updateTodoRequest); + if (updateTodo is null) + throw new InvalidOperationException("Could not parse invalid updateTodo"); + + var updatedTodo = await _todoRepository.UpdateTodoAsync(new Core.Entities.Todo() { - var updateTodo = JsonSerializer.Deserialize(updateTodoRequest); - if (updateTodo is null) - throw new InvalidOperationException("Could not parse invalid updateTodo"); + Id = updateTodo.Id, + Project = updateTodo.Project, + Status = updateTodo.Status, + Title = updateTodo.Title + }); - var updatedTodo = await _todoRepository.UpdateTodoAsync(new Core.Entities.Todo() - { - Id = updateTodo.Id, - Project = updateTodo.Project, - Status = updateTodo.Status, - Title = updateTodo.Title - }); + var serializedTodo = JsonSerializer.Serialize(new TodoResponse() + { + Id = updatedTodo.Id, + Project = updatedTodo.Project, + Status = updatedTodo.Status, + Title = updatedTodo.Title, + }); - var serializedTodo = JsonSerializer.Serialize(new TodoResponse() - { - Id = updatedTodo.Id, - Project = updatedTodo.Project, - Status = updatedTodo.Status, - Title = updatedTodo.Title, - }); + await RunOnUserConnections(async (connections) => + await Clients.Clients(connections).SendAsync("getTodo", serializedTodo)); + } - await Clients.Caller.SendAsync("getTodo", serializedTodo); - } + private Task RunOnUserConnections(Func, Task> action) + { + var userId = Context.User.FindFirstValue("sub"); + if (userId is null) + throw new InvalidOperationException("User id was invalid. Something has gone terribly wrong"); + + ConnectedUsers.TryGetValue(userId, out var connections); + + if (connections is not null) + action(connections); + + return Task.CompletedTask; } } \ No newline at end of file diff --git a/src/backend/server/src/Todo.Api/Startup.cs b/src/backend/server/src/Todo.Api/Startup.cs index 563b976..007e562 100644 --- a/src/backend/server/src/Todo.Api/Startup.cs +++ b/src/backend/server/src/Todo.Api/Startup.cs @@ -27,6 +27,7 @@ namespace Todo.Api public void ConfigureServices(IServiceCollection services) { services.AddControllers(); + services.AddHttpContextAccessor(); services.AddCors(options => { options.AddDefaultPolicy(builder => diff --git a/src/backend/server/src/Todo.Api/Todo.Api.csproj b/src/backend/server/src/Todo.Api/Todo.Api.csproj index d28be72..1e68ddb 100644 --- a/src/backend/server/src/Todo.Api/Todo.Api.csproj +++ b/src/backend/server/src/Todo.Api/Todo.Api.csproj @@ -3,6 +3,7 @@ net6.0 Linux + enable diff --git a/src/client/src/presentation/contexts/SocketContext.tsx b/src/client/src/presentation/contexts/SocketContext.tsx index c6e82b6..7278212 100644 --- a/src/client/src/presentation/contexts/SocketContext.tsx +++ b/src/client/src/presentation/contexts/SocketContext.tsx @@ -40,7 +40,9 @@ export const SocketProvider: FC = (props) => { process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:5000"; const connection = new HubConnectionBuilder() - .withUrl(`${serverUrl}/hubs/todo`) + .withUrl(`${serverUrl}/hubs/todo`, { + withCredentials: true + }) .withAutomaticReconnect() .configureLogging(LogLevel.Information) .build();