diff --git a/src/backend/server/src/Todo.Api/Controllers/TodosController.cs b/src/backend/server/src/Todo.Api/Controllers/TodosController.cs index d10ce0d..26c1dca 100644 --- a/src/backend/server/src/Todo.Api/Controllers/TodosController.cs +++ b/src/backend/server/src/Todo.Api/Controllers/TodosController.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using System.Security.Claims; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Todo.Core.Interfaces.Persistence; @@ -17,8 +18,14 @@ public class TodosController : ControllerBase } [HttpPost] - public async Task> CreateTodo([FromBody] CreateTodoRequest request) => - Ok(await _todoRepository.CreateTodoAsync(request.Title, String.Empty)); + [Authorize] + public async Task> CreateTodo([FromBody] CreateTodoRequest request) + { + var userId = User.FindFirstValue("sub") ?? + throw new InvalidOperationException("Could not get user, something has gone terribly wrong"); + + return Ok(await _todoRepository.CreateTodoAsync(request.Title, String.Empty, userId)); + } [HttpGet] [Authorize] diff --git a/src/backend/server/src/Todo.Api/Hubs/TodoHub.cs b/src/backend/server/src/Todo.Api/Hubs/TodoHub.cs index d612959..2605bf6 100644 --- a/src/backend/server/src/Todo.Api/Hubs/TodoHub.cs +++ b/src/backend/server/src/Todo.Api/Hubs/TodoHub.cs @@ -1,11 +1,15 @@ using System.Collections.Concurrent; using System.Security.Claims; using System.Text.Json; +using MediatR; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.SignalR; using Todo.Api.Hubs.Models; +using Todo.Core.Application.Commands.Todo; +using Todo.Core.Application.Services.UserConnectionStore; using Todo.Core.Interfaces.Persistence; +using Todo.Core.Interfaces.User; +using Todo.Infrastructure; namespace Todo.Api.Hubs; @@ -13,85 +17,59 @@ namespace Todo.Api.Hubs; public class TodoHub : Hub { private readonly ITodoRepository _todoRepository; - - private static readonly ConcurrentDictionary> ConnectedUsers = new(); + private readonly IUserConnectionStore _userConnectionStore; + private readonly ICurrentUserService _currentUserService; + private readonly IMediator _mediator; public override Task OnConnectedAsync() { - var userId = Context.User.FindFirstValue("sub"); - - // 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); - + var userId = _currentUserService.GetUserId(); + if (userId is not null) + _userConnectionStore.AddAsync(userId, Context.ConnectionId); 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) - { - // if there are no connections for the user, - // just delete the userName key from the ConnectedUsers concurent dictionary - ConnectedUsers.TryRemove(userId, out _); - } + var userId = _currentUserService.GetUserId(); + if (userId is not null) + _userConnectionStore.RemoveAsync(userId, Context.ConnectionId); return base.OnDisconnectedAsync(exception); } - - public TodoHub(ITodoRepository todoRepository) + public TodoHub( + ITodoRepository todoRepository, + IUserConnectionStore userConnectionStore, + ICurrentUserService currentUserService, + IMediator mediator) { _todoRepository = todoRepository; + _userConnectionStore = userConnectionStore; + _currentUserService = currentUserService; + _mediator = mediator; } - public async Task CreateTodo(string todoTitle, string projectName) + 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, Project = t.Project }) - .ToList()); + //var userId = GetUserId(); - await RunOnUserConnections(async (connections) => - await Clients.Clients(connections).SendAsync("getInboxTodos", serializedTodos)); + //var _ = await _todoRepository.CreateTodoAsync(todoTitle, projectName, userId); + await _mediator.Send(new CreateTodoCommand(todoTitle, projectName)); + + await GetInboxTodos(); } public async Task UpdateTodo(string todoId, bool todoStatus) { - await _todoRepository.UpdateTodoStatus(todoId, todoStatus); + var userId = GetUserId(); + await _todoRepository.UpdateTodoStatus(todoId, todoStatus, userId); - 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)); + await GetInboxTodos(); } public async Task GetTodos() @@ -157,17 +135,15 @@ public class TodoHub : Hub await Clients.Clients(connections).SendAsync("getTodo", serializedTodo)); } - private Task RunOnUserConnections(Func, Task> action) + private async 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"); + var userId = GetUserId(); + var connections = await _userConnectionStore.GetConnectionsAsync(userId); - ConnectedUsers.TryGetValue(userId, out var connections); - - if (connections is not null) - action(connections); - - return Task.CompletedTask; + await action(connections); } + + private string GetUserId() => + _currentUserService.GetUserId() ?? + throw new InvalidOperationException("User id was invalid. Something has gone terribly wrong"); } \ No newline at end of file diff --git a/src/backend/server/src/Todo.Api/Publishers/TodoPublisher.cs b/src/backend/server/src/Todo.Api/Publishers/TodoPublisher.cs new file mode 100644 index 0000000..2b3b998 --- /dev/null +++ b/src/backend/server/src/Todo.Api/Publishers/TodoPublisher.cs @@ -0,0 +1,42 @@ +using System.Threading; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; +using Todo.Api.Hubs; +using Todo.Core.Application.Services.UserConnectionStore; +using Todo.Core.Interfaces.Publisher; +using Todo.Core.Interfaces.User; + +namespace Todo.Api.Publishers; + +public class TodoPublisher : ITodoPublisher +{ + private readonly IHubContext _hubContext; + private readonly ICurrentUserService _currentUserService; + private readonly IUserConnectionStore _userConnectionStore; + private readonly ILogger _logger; + + public TodoPublisher( + IHubContext hubContext, + ICurrentUserService currentUserService, + IUserConnectionStore userConnectionStore, + ILogger logger) + { + _hubContext = hubContext; + _currentUserService = currentUserService; + _userConnectionStore = userConnectionStore; + _logger = logger; + } + + public async Task Publish(string todoId, CancellationToken cancellationToken) + { + var userId = _currentUserService.GetUserId() ?? throw new InvalidOperationException("Cannot proceed without user"); + var connections = await _userConnectionStore.GetConnectionsAsync(userId); + + await _hubContext + .Clients + .Clients(connections) + .SendAsync("todoCreated", todoId , cancellationToken); + + _logger.LogInformation("todo created {TodoId}", todoId); + } +} \ No newline at end of file diff --git a/src/backend/server/src/Todo.Api/Services/HttpContextCurrentUserService.cs b/src/backend/server/src/Todo.Api/Services/HttpContextCurrentUserService.cs new file mode 100644 index 0000000..11a66f7 --- /dev/null +++ b/src/backend/server/src/Todo.Api/Services/HttpContextCurrentUserService.cs @@ -0,0 +1,17 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Todo.Core.Interfaces.User; + +namespace Todo.Api.Services; + +public class HttpContextCurrentUserService : ICurrentUserService +{ + private readonly IHttpContextAccessor _httpContextAccessor; + + public HttpContextCurrentUserService(IHttpContextAccessor httpContextAccessor) + { + _httpContextAccessor = httpContextAccessor; + } + + public string? GetUserId() => _httpContextAccessor.HttpContext?.User.FindFirstValue("sub"); +} \ 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 007e562..c791736 100644 --- a/src/backend/server/src/Todo.Api/Startup.cs +++ b/src/backend/server/src/Todo.Api/Startup.cs @@ -8,9 +8,14 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.OpenApi.Models; using Todo.Api.Hubs; +using Todo.Api.Publishers; +using Todo.Api.Services; +using Todo.Core.Interfaces.User; using Todo.Infrastructure; using Todo.Persistence; using Todo.Persistence.Mongo; +using Todo.Core; +using Todo.Core.Interfaces.Publisher; namespace Todo.Api { @@ -37,6 +42,10 @@ namespace Todo.Api .AllowAnyMethod()); }); + services.AddCore(); + services.AddScoped(); + services.AddScoped(); + services.AddSwaggerGen(c => { c.SwaggerDoc("v1", new OpenApiInfo { Title = "Todo.Api", Version = "v1" }); diff --git a/src/backend/server/src/Todo.Core/Application/Commands/Todo/CreateTodoCommand.cs b/src/backend/server/src/Todo.Core/Application/Commands/Todo/CreateTodoCommand.cs new file mode 100644 index 0000000..2c6bff4 --- /dev/null +++ b/src/backend/server/src/Todo.Core/Application/Commands/Todo/CreateTodoCommand.cs @@ -0,0 +1,37 @@ +using System.Threading; +using MediatR; +using Todo.Core.Application.Notifications.Todo; +using Todo.Core.Interfaces.Persistence; +using Todo.Core.Interfaces.User; + +namespace Todo.Core.Application.Commands.Todo; + +public record CreateTodoCommand(string TodoTitle, string? TodoProject) : IRequest +{ + internal class Handler : IRequestHandler + { + private readonly ICurrentUserService _currentUserService; + private readonly ITodoRepository _todoRepository; + private readonly IMediator _mediator; + + public Handler(ICurrentUserService currentUserService, ITodoRepository todoRepository, IMediator mediator) + { + _currentUserService = currentUserService; + _todoRepository = todoRepository; + _mediator = mediator; + } + + public async Task Handle(CreateTodoCommand request, CancellationToken cancellationToken) + { + var userId = _currentUserService.GetUserId(); + if (userId is null) + throw new InvalidOperationException("User was not found"); + + var todo = await _todoRepository.CreateTodoAsync(request.TodoTitle, request.TodoProject, userId); + + await _mediator.Publish(new TodoCreated(todo.Id), cancellationToken); + + return todo.Id; + } + } +} \ No newline at end of file diff --git a/src/backend/server/src/Todo.Core/Application/Notifications/Todo/TodoCreated.cs b/src/backend/server/src/Todo.Core/Application/Notifications/Todo/TodoCreated.cs new file mode 100644 index 0000000..5f538db --- /dev/null +++ b/src/backend/server/src/Todo.Core/Application/Notifications/Todo/TodoCreated.cs @@ -0,0 +1,28 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using MediatR; +using Microsoft.AspNetCore.SignalR; +using Todo.Core.Application.Services.UserConnectionStore; +using Todo.Core.Interfaces.Publisher; +using Todo.Core.Interfaces.User; + +namespace Todo.Core.Application.Notifications.Todo; + +public record TodoCreated([property: JsonPropertyName("todoId")] string TodoId) : INotification +{ + internal class Handler : INotificationHandler + { + private readonly ITodoPublisher _todoPublisher; + + public Handler(ITodoPublisher todoPublisher) + { + _todoPublisher = todoPublisher; + } + + public async Task Handle(TodoCreated notification, CancellationToken cancellationToken) + { + await _todoPublisher.Publish(JsonSerializer.Serialize(notification), cancellationToken); + } + } +} \ No newline at end of file diff --git a/src/backend/server/src/Todo.Core/Application/Publisher/ITodoPublisher.cs b/src/backend/server/src/Todo.Core/Application/Publisher/ITodoPublisher.cs new file mode 100644 index 0000000..754ab05 --- /dev/null +++ b/src/backend/server/src/Todo.Core/Application/Publisher/ITodoPublisher.cs @@ -0,0 +1,8 @@ +using System.Threading; + +namespace Todo.Core.Interfaces.Publisher; + +public interface ITodoPublisher +{ + Task Publish(string todoId, CancellationToken cancellationToken = new ()); +} \ No newline at end of file diff --git a/src/backend/server/src/Todo.Core/Application/Services/UserConnectionStore/IUserConnectionStore.cs b/src/backend/server/src/Todo.Core/Application/Services/UserConnectionStore/IUserConnectionStore.cs new file mode 100644 index 0000000..0d9f3e4 --- /dev/null +++ b/src/backend/server/src/Todo.Core/Application/Services/UserConnectionStore/IUserConnectionStore.cs @@ -0,0 +1,8 @@ +namespace Todo.Core.Application.Services.UserConnectionStore; + +public interface IUserConnectionStore +{ + Task AddAsync(string userId, string connectionId); + Task RemoveAsync(string userId, string connectionId); + Task> GetConnectionsAsync(string userId); +} diff --git a/src/backend/server/src/Todo.Core/DependencyInjection.cs b/src/backend/server/src/Todo.Core/DependencyInjection.cs index b1925cd..6d62ced 100644 --- a/src/backend/server/src/Todo.Core/DependencyInjection.cs +++ b/src/backend/server/src/Todo.Core/DependencyInjection.cs @@ -2,9 +2,13 @@ global using System; global using System.Linq; global using System.Threading.Tasks; global using System.Collections.Generic; +using System.Reflection; +using MediatR; +using Microsoft.Extensions.DependencyInjection; namespace Todo.Core; public static class DependencyInjection { + public static IServiceCollection AddCore(this IServiceCollection services) => services.AddMediatR(Assembly.GetExecutingAssembly()); } \ No newline at end of file diff --git a/src/backend/server/src/Todo.Core/Interfaces/Persistence/ITodoRepository.cs b/src/backend/server/src/Todo.Core/Interfaces/Persistence/ITodoRepository.cs index b70c410..e25b0ac 100644 --- a/src/backend/server/src/Todo.Core/Interfaces/Persistence/ITodoRepository.cs +++ b/src/backend/server/src/Todo.Core/Interfaces/Persistence/ITodoRepository.cs @@ -3,9 +3,9 @@ namespace Todo.Core.Interfaces.Persistence; public interface ITodoRepository { - Task CreateTodoAsync(string title, string projectName); + Task CreateTodoAsync(string title, string projectName, string userId); Task> GetTodosAsync(); - Task UpdateTodoStatus(string todoId, bool todoStatus); + Task UpdateTodoStatus(string todoId, bool todoStatus, string userId); Task> GetNotDoneTodos(); Task GetTodoByIdAsync(string todoId); Task UpdateTodoAsync(Entities.Todo todo); diff --git a/src/backend/server/src/Todo.Core/Interfaces/Persistence/IUserRepository.cs b/src/backend/server/src/Todo.Core/Interfaces/Persistence/IUserRepository.cs index 80fabe4..87ee453 100644 --- a/src/backend/server/src/Todo.Core/Interfaces/Persistence/IUserRepository.cs +++ b/src/backend/server/src/Todo.Core/Interfaces/Persistence/IUserRepository.cs @@ -4,5 +4,5 @@ namespace Todo.Core.Interfaces.Persistence; public interface IUserRepository { - Task Register(string email, string password); + Task Register(string email, string password); } \ No newline at end of file diff --git a/src/backend/server/src/Todo.Core/Interfaces/User/ICurrentUserService.cs b/src/backend/server/src/Todo.Core/Interfaces/User/ICurrentUserService.cs new file mode 100644 index 0000000..5e4b081 --- /dev/null +++ b/src/backend/server/src/Todo.Core/Interfaces/User/ICurrentUserService.cs @@ -0,0 +1,6 @@ +namespace Todo.Core.Interfaces.User; + +public interface ICurrentUserService +{ + string? GetUserId(); +} \ No newline at end of file diff --git a/src/backend/server/src/Todo.Core/Todo.Core.csproj b/src/backend/server/src/Todo.Core/Todo.Core.csproj index 4f444d8..823c1a6 100644 --- a/src/backend/server/src/Todo.Core/Todo.Core.csproj +++ b/src/backend/server/src/Todo.Core/Todo.Core.csproj @@ -2,6 +2,20 @@ net6.0 + enable + + + + + + + + + + + + + diff --git a/src/backend/server/src/Todo.Infrastructure/DependencyInjection.cs b/src/backend/server/src/Todo.Infrastructure/DependencyInjection.cs index aa5f607..5647920 100644 --- a/src/backend/server/src/Todo.Infrastructure/DependencyInjection.cs +++ b/src/backend/server/src/Todo.Infrastructure/DependencyInjection.cs @@ -3,7 +3,8 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Todo.Core.Application.Services.UserConnectionStore; +using Todo.Infrastructure.UserConnectionStore; namespace Todo.Infrastructure; @@ -20,6 +21,7 @@ public static class DependencyInjection .Bind(giteaOptions) .ValidateDataAnnotations(); + services.AddSingleton(); return services.AddAuthentication(options => { diff --git a/src/backend/server/src/Todo.Infrastructure/Todo.Infrastructure.csproj b/src/backend/server/src/Todo.Infrastructure/Todo.Infrastructure.csproj index 79bb0ac..39c1189 100644 --- a/src/backend/server/src/Todo.Infrastructure/Todo.Infrastructure.csproj +++ b/src/backend/server/src/Todo.Infrastructure/Todo.Infrastructure.csproj @@ -13,5 +13,9 @@ + + + + diff --git a/src/backend/server/src/Todo.Infrastructure/UserConnectionStore/InMemoryUserConnectionStore.cs b/src/backend/server/src/Todo.Infrastructure/UserConnectionStore/InMemoryUserConnectionStore.cs new file mode 100644 index 0000000..e580df6 --- /dev/null +++ b/src/backend/server/src/Todo.Infrastructure/UserConnectionStore/InMemoryUserConnectionStore.cs @@ -0,0 +1,50 @@ +using System.Collections.Concurrent; +using Todo.Core.Application.Services.UserConnectionStore; + +namespace Todo.Infrastructure.UserConnectionStore; + +class InMemoryUserConnectionStore : IUserConnectionStore +{ + private static readonly ConcurrentDictionary> ConnectedUsers = new(); + + public Task AddAsync(string userId, string connectionId) + { + // 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(connectionId); + + // Add to the global dictionary of connected users + ConnectedUsers.TryAdd(userId, existingUserConnectionIds); + + return Task.CompletedTask; + } + + public Task> GetConnectionsAsync(string userId) + { + ConnectedUsers.TryGetValue(userId, out var connections); + return Task.FromResult(connections is null ? new List().AsEnumerable() : connections.AsEnumerable()); + } + + public Task RemoveAsync(string userId, string connectionId) + { + ConnectedUsers.TryGetValue(userId, out var existingUserConnectionIds); + + // remove the connection id from the List + existingUserConnectionIds?.Remove(connectionId); + + // If there are no connection ids in the List, delete the user from the global cache (ConnectedUsers). + if (existingUserConnectionIds?.Count == 0) + { + // if there are no connections for the user, + // just delete the userName key from the ConnectedUsers concurent dictionary + ConnectedUsers.TryRemove(userId, out _); + } + + return Task.CompletedTask; + } +} diff --git a/src/backend/server/src/Todo.Persistence/Mongo/Repositories/Dtos/MongoTodo.cs b/src/backend/server/src/Todo.Persistence/Mongo/Repositories/Dtos/MongoTodo.cs index c9d645e..cf91a54 100644 --- a/src/backend/server/src/Todo.Persistence/Mongo/Repositories/Dtos/MongoTodo.cs +++ b/src/backend/server/src/Todo.Persistence/Mongo/Repositories/Dtos/MongoTodo.cs @@ -12,4 +12,5 @@ public record MongoTodo [BsonRequired] public string Title { get; init; } [BsonRequired] public bool Status { get; set; } public string ProjectName { get; set; } = String.Empty; + public string AuthorId { get; set; } } \ No newline at end of file diff --git a/src/backend/server/src/Todo.Persistence/Mongo/Repositories/TodoRepository.cs b/src/backend/server/src/Todo.Persistence/Mongo/Repositories/TodoRepository.cs index 445413c..33371b1 100644 --- a/src/backend/server/src/Todo.Persistence/Mongo/Repositories/TodoRepository.cs +++ b/src/backend/server/src/Todo.Persistence/Mongo/Repositories/TodoRepository.cs @@ -15,9 +15,9 @@ public class TodoRepository : ITodoRepository _todosCollection = database.GetCollection("todos"); } - public async Task CreateTodoAsync(string title, string projectName) + public async Task CreateTodoAsync(string title, string projectName, string userId) { - var todo = new MongoTodo() { Title = title, ProjectName = projectName }; + var todo = new MongoTodo() { Title = title, ProjectName = projectName, AuthorId = userId }; await _todosCollection.InsertOneAsync(todo); return new Core.Entities.Todo() { Id = todo.Id, Title = todo.Title, Status = false, Project = todo.ProjectName }; @@ -32,10 +32,10 @@ public class TodoRepository : ITodoRepository new Core.Entities.Todo() { Id = t.Id, Title = t.Title, Status = t.Status, Project = t.ProjectName }); } - public async Task UpdateTodoStatus(string todoId, bool todoStatus) + public async Task UpdateTodoStatus(string todoId, bool todoStatus, string userId) { await _todosCollection - .UpdateOneAsync(t => t.Id == todoId, + .UpdateOneAsync(t => t.Id == todoId && t.AuthorId == userId, Builders.Update.Set(t => t.Status, todoStatus)); } diff --git a/src/client/src/components/todos/todoCheckmark.tsx b/src/client/src/components/todos/todoCheckmark.tsx index 9314816..8ede547 100644 --- a/src/client/src/components/todos/todoCheckmark.tsx +++ b/src/client/src/components/todos/todoCheckmark.tsx @@ -11,7 +11,7 @@ export const TodoCheckmark: FC = (props) => ( onClick={() => props.updateTodo({ ...props.todo, status: !props.todo.status }) } - className={`todo-checkmark h-5 w-5 rounded-full border dark:border-gray-500 ${ + className={`todo-checkmark h-5 w-5 rounded-full border dark:border-gray-500 cursor-pointer ${ props.todo.status === StatusState.done ? "bg-gray-300 dark:bg-gray-500" : "hover:bg-gray-200 hover:dark:bg-gray-600"