diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..c13be9d --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,4 @@ +# Copilot Instructions + +## Project Guidelines +- No crear pruebas/tests si el usuario no lo solicita explícitamente; evitar cambios no pedidos. \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..41a2ff6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,41 @@ +# Contexto del proyecto para agentes de IA + +## Resumen rápido +- Proyecto backend en `.NET 10`. +- Estructura principal en capas: `Domain`, `Application`, `Infrastructure`. +- Patrón arquitectónico: `Clean Architecture` con enfoque `CQRS`. + +## Arquitectura +- **Domain (`Domain`)**: entidades de negocio puras. +- **Application (`Application`)**: + - Casos de uso (comandos/queries y handlers). + - Contratos (`Application.Contracts`). + - Configuración de aplicación (`Application.Configuration`). + - Mensajería orientada a eventos a través de `IEventBus`. +- **Infrastructure (`Infrastructure`)**: + - Persistencia y acceso a datos. + - Integraciones externas (Keycloak, almacenamiento, procesamiento de imágenes). + - Implementación de mensajería y consumidores. + +## CQRS / Mediación +- Se usa **DispatchR** (paquete `DispatchR.Mediator`) como variante de MediatR para orquestar comandos y consultas. +- Los casos de uso están organizados por feature con handlers dedicados (`Commands`, `Queries`). + +## Mensajería y workers +- Se usa **MassTransit** para publicación/consumo de eventos. +- Broker actual: **RabbitMQ** (`MassTransit.RabbitMQ`). +- Hay modo de registro para workers con consumidores en `Infrastructure.Messaging.Consumers`. +- Actualmente existen consumidores como: + - `ResizePictureConsumer` + - `RemoveFileConsumer` + +## Persistencia e integraciones +- `Entity Framework Core` + proveedor `Npgsql` (PostgreSQL). +- Integración de identidad con `Keycloak`. +- Almacenamiento con `AWS S3`. + +## Notas para agentes +- Mantener separación de responsabilidades por capa. +- Evitar lógica de infraestructura dentro de `Application` y `Domain`. +- Agregar nuevos casos de uso siguiendo el patrón CQRS existente (comando/query + handler). +- Para nuevas integraciones asíncronas, reutilizar el pipeline de `MassTransit` y la abstracción `IEventBus`. diff --git a/Application/Configuration/ConfigurationDependencyInjection.cs b/Application/Configuration/ConfigurationDependencyInjection.cs index 903d489..4630946 100644 --- a/Application/Configuration/ConfigurationDependencyInjection.cs +++ b/Application/Configuration/ConfigurationDependencyInjection.cs @@ -9,7 +9,7 @@ namespace Musify.Application.Configuration public static IServiceCollection AddApplicationConfigurations(this IServiceCollection serviceDescriptors, IConfiguration configuration) { serviceDescriptors - .AddValidatedConfiguration(configuration, StorageConfiguration.SectionName) + .AddValidatedConfiguration(configuration, StorageSettings.SectionName) .AddValidatedConfiguration(configuration, PlayListConfiguration.SectionName); return serviceDescriptors; diff --git a/Application/Configuration/StorageConfiguration.cs b/Application/Configuration/StorageConfiguration.cs deleted file mode 100644 index 70ea2b6..0000000 --- a/Application/Configuration/StorageConfiguration.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace Musify.Application.Configuration -{ -#pragma warning disable CS8618 - public class StorageConfiguration - { - public const string SectionName = "Storage"; - - [Required] - public string BucketName { get; set; } - - [Required] - public string ServiceUrl { get; set; } - - [Required] - public string AccessKey { get; set; } - - [Required] - public string SecretAccessKey { get; set; } - - [Required] - public bool ForcePathStyle { get; set; } - - [Required] - public bool UseHttp { get; set; } - } -} \ No newline at end of file diff --git a/Application/Configuration/StorageSettings.cs b/Application/Configuration/StorageSettings.cs new file mode 100644 index 0000000..fa912b5 --- /dev/null +++ b/Application/Configuration/StorageSettings.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; + +namespace Musify.Application.Configuration +{ + public class StorageSettings + { + public const string SectionName = "Storage"; + + [Required] + public required string BucketName { get; set; } + } +} diff --git a/Application/PlayLists/Commands/CreatePlayList/CreatePlayListCommandHandler.cs b/Application/PlayLists/Commands/CreatePlayList/CreatePlayListCommandHandler.cs index 32c3ee8..3951ffd 100644 --- a/Application/PlayLists/Commands/CreatePlayList/CreatePlayListCommandHandler.cs +++ b/Application/PlayLists/Commands/CreatePlayList/CreatePlayListCommandHandler.cs @@ -12,7 +12,7 @@ using Musify.Domain.Entities; namespace Musify.Application.PlayLists.Commands.CreatePlayList { public class CreatePlayListCommandHandler(IPublishEndpoint publishEndpoint, IDatabase database, IStorageHandler storageHandler, - ILogger logger, StorageConfiguration storageConfiguration, PlayListConfiguration playListConfiguration) + ILogger logger, StorageSettings storageConfiguration, PlayListConfiguration playListConfiguration) : IRequestHandler> > { public async Task> Handle(CreatePlayListCommand request, CancellationToken cancellationToken) @@ -57,7 +57,7 @@ namespace Musify.Application.PlayLists.Commands.CreatePlayList return Result.Error($"Failed to upload picture for PlayList with Id {playList.Id} to storage."); } - logger.LogInformation("PlayList with Id={PlayListId} created successfully for UserId={UserId}.", playList.Id, request.UserId); + logger.LogInformation("Playlist image uploaded successfully for PlayList with Id={PlayListId}.", playList.Id); await publishEndpoint.Publish(new ResizePictureEvent( storageConfiguration.BucketName, @@ -85,7 +85,7 @@ namespace Musify.Application.PlayLists.Commands.CreatePlayList await database.PlayLists.AddAsync(playList, cancellationToken); await database.SaveChangesAsync(cancellationToken); - logger.LogInformation("Picture for PlayList with Id={PlayListId} ", playList.Id); + logger.LogInformation("PlayList with id={PlayListId} created successfully for user with id={UserId}.", playList.Id, request.UserId); return Result.Created(PlayListResponse.FromEntity(playList)); } } diff --git a/Application/PlayLists/Commands/DeletePlayList/DeletePlayListCommandHandler.cs b/Application/PlayLists/Commands/DeletePlayList/DeletePlayListCommandHandler.cs index ca066f3..0b73404 100644 --- a/Application/PlayLists/Commands/DeletePlayList/DeletePlayListCommandHandler.cs +++ b/Application/PlayLists/Commands/DeletePlayList/DeletePlayListCommandHandler.cs @@ -9,7 +9,7 @@ using Musify.Application.Events; namespace Musify.Application.PlayLists.Commands.DeletePlayList { public class DeletePlayListCommandHandler(IDatabase database, IPublishEndpoint publishEndpoint, - StorageConfiguration storageConfiguration, PlayListConfiguration playListConfiguration, ILogger logger) + StorageSettings storageConfiguration, PlayListConfiguration playListConfiguration, ILogger logger) : IRequestHandler> { public async Task Handle(DeletePlayListCommand request, CancellationToken cancellationToken) diff --git a/Application/PlayLists/Commands/UpdatePlayList/UpdatePlayListCommandHandler.cs b/Application/PlayLists/Commands/UpdatePlayList/UpdatePlayListCommandHandler.cs index 4a7b7de..337b122 100644 --- a/Application/PlayLists/Commands/UpdatePlayList/UpdatePlayListCommandHandler.cs +++ b/Application/PlayLists/Commands/UpdatePlayList/UpdatePlayListCommandHandler.cs @@ -11,7 +11,7 @@ using Musify.Application.PlayLists.Contracts; namespace Musify.Application.PlayLists.Commands.UpdatePlayList { public class UpdatePlayListCommandHandler(IPublishEndpoint publishEndpoint, IDatabase database, IStorageHandler storageHandler, - ILogger logger, StorageConfiguration storageConfiguration, PlayListConfiguration playListConfiguration) + ILogger logger, StorageSettings storageConfiguration, PlayListConfiguration playListConfiguration) : IRequestHandler> > { public async Task> Handle(UpdatePlayListCommand request, CancellationToken cancellationToken) @@ -40,7 +40,7 @@ namespace Musify.Application.PlayLists.Commands.UpdatePlayList { database.PlayLists.Update(playList); await database.SaveChangesAsync(cancellationToken); - logger.LogInformation("Updated PlayList with id={PlayListId}.", playList.Id); + logger.LogInformation("Updated PlayList with id={PlayListId} without picture changes.", playList.Id); return Result.Success(PlayListResponse.FromEntity(playList)); } @@ -85,7 +85,7 @@ namespace Musify.Application.PlayLists.Commands.UpdatePlayList return Result.Error($"Failed to upload picture for PlayList with Id {playList.Id} to storage."); } - playList.OriginalPictureKeyName = newImageId.ToString(); + playList.OriginalPictureKeyName = imageKeyName; await publishEndpoint.Publish(new ResizePictureEvent( storageConfiguration.BucketName, imageKeyName, @@ -108,7 +108,7 @@ namespace Musify.Application.PlayLists.Commands.UpdatePlayList database.PlayLists.Update(playList); await database.SaveChangesAsync(cancellationToken); - logger.LogInformation("Updated PlayList with id={PlayListId}.", playList.Id); + logger.LogInformation("Updated PlayList with id={PlayListId} and picture key {PictureKeyName}.", playList.Id, imageKeyName); return Result.Success(PlayListResponse.FromEntity(playList)); } } diff --git a/Application/Users/Commands/CreateUser/CreateUserCommandHandler.cs b/Application/Users/Commands/CreateUser/CreateUserCommandHandler.cs index f3f7773..2e72d70 100644 --- a/Application/Users/Commands/CreateUser/CreateUserCommandHandler.cs +++ b/Application/Users/Commands/CreateUser/CreateUserCommandHandler.cs @@ -15,15 +15,21 @@ namespace Musify.Application.Users.Commands.CreateUser { logger.LogDebug("Handling {CommandName} with Id: {UserId}", nameof(CreateUserCommand), request.Id); - var existingUser = database.Users.SingleOrDefaultAsync(u => u.Id == request.Id, cancellationToken); + var existingUser = await database.Users.SingleOrDefaultAsync(u => u.Id == request.Id, cancellationToken); if (existingUser is not null) + { + logger.LogInformation("User with Id={UserId} already exists.", request.Id); return Result.Conflict($"A user with Id {request.Id} already exists."); + } var keycloakUser = await keycloakUserClient.GetUserByIdAsync(request.Id.ToString(), cancellationToken); if (!keycloakUser.IsSuccess) + { + logger.LogWarning("Keycloak user with Id={UserId} was not found.", request.Id); return Result.NotFound("Keycloak user not found."); + } var newUser = new User { @@ -31,9 +37,9 @@ namespace Musify.Application.Users.Commands.CreateUser }; await database.Users.AddAsync(newUser, cancellationToken); - await database.SaveChangesAsync(cancellationToken); + logger.LogInformation("User with Id={UserId} created successfully.", newUser.Id); return Result.Created(new UserResponse( newUser.Id.ToString(), keycloakUser.Value.Name, diff --git a/Application/Users/Queries/GetUserByKeycloakId/GetUserByKeycloakCommand.cs b/Application/Users/Queries/GetUserByKeycloakId/GetUserByKeycloakQuery.cs similarity index 91% rename from Application/Users/Queries/GetUserByKeycloakId/GetUserByKeycloakCommand.cs rename to Application/Users/Queries/GetUserByKeycloakId/GetUserByKeycloakQuery.cs index 811578a..55910b2 100644 --- a/Application/Users/Queries/GetUserByKeycloakId/GetUserByKeycloakCommand.cs +++ b/Application/Users/Queries/GetUserByKeycloakId/GetUserByKeycloakQuery.cs @@ -1,4 +1,4 @@ -using Ardalis.Result; +using Ardalis.Result; using DispatchR.Abstractions.Send; using Musify.Application.Users.Contracts; @@ -6,4 +6,4 @@ namespace Musify.Application.Users.Queries.GetUserByKeycloakId { public record GetUserByKeycloakQuery(Guid Id) : IRequest >>; -} \ No newline at end of file +} diff --git a/Application/Users/Queries/GetUserByKeycloakId/GetUserByKeycloakCommandHandler.cs b/Application/Users/Queries/GetUserByKeycloakId/GetUserByKeycloakQueryHandler.cs similarity index 98% rename from Application/Users/Queries/GetUserByKeycloakId/GetUserByKeycloakCommandHandler.cs rename to Application/Users/Queries/GetUserByKeycloakId/GetUserByKeycloakQueryHandler.cs index a77ed6e..bca8ee5 100644 --- a/Application/Users/Queries/GetUserByKeycloakId/GetUserByKeycloakCommandHandler.cs +++ b/Application/Users/Queries/GetUserByKeycloakId/GetUserByKeycloakQueryHandler.cs @@ -1,4 +1,4 @@ -using Ardalis.Result; +using Ardalis.Result; using DispatchR.Abstractions.Send; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts.Infrastructure; @@ -32,4 +32,4 @@ namespace Musify.Application.Users.Queries.GetUserByKeycloakId user.UpdatedAt)); } } -} \ No newline at end of file +} diff --git a/Application/Users/Queries/GetUsers/GetUsersCommand.cs b/Application/Users/Queries/GetUsers/GetUsersCommand.cs deleted file mode 100644 index 77e6969..0000000 --- a/Application/Users/Queries/GetUsers/GetUsersCommand.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Ardalis.Result; -using DispatchR.Abstractions.Send; -using Musify.Application.Users.Contracts; - -namespace Musify.Application.Users.Queries.GetUsers -{ - public record GetUsersCommand(int PageNumber, int PageSize, string UsernameSearch) - : IRequest> >; -} \ No newline at end of file diff --git a/Application/Users/Queries/GetUsers/GetUsersQuery.cs b/Application/Users/Queries/GetUsers/GetUsersQuery.cs new file mode 100644 index 0000000..7d3aeb2 --- /dev/null +++ b/Application/Users/Queries/GetUsers/GetUsersQuery.cs @@ -0,0 +1,9 @@ +using Ardalis.Result; +using DispatchR.Abstractions.Send; +using Musify.Application.Users.Contracts; + +namespace Musify.Application.Users.Queries.GetUsers +{ + public record GetUsersQuery(int PageNumber, int PageSize, string UsernameSearch) + : IRequest> >; +} diff --git a/Application/Users/Queries/GetUsers/GetUsersCommandHandler.cs b/Application/Users/Queries/GetUsers/GetUsersQueryHandler.cs similarity index 92% rename from Application/Users/Queries/GetUsers/GetUsersCommandHandler.cs rename to Application/Users/Queries/GetUsers/GetUsersQueryHandler.cs index f975fff..dc4d704 100644 --- a/Application/Users/Queries/GetUsers/GetUsersCommandHandler.cs +++ b/Application/Users/Queries/GetUsers/GetUsersQueryHandler.cs @@ -1,4 +1,4 @@ -using Ardalis.Result; +using Ardalis.Result; using DispatchR.Abstractions.Send; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts.Infrastructure; @@ -9,9 +9,9 @@ using X.PagedList.Extensions; namespace Musify.Application.Users.Queries.GetUsers { public class GetUsersQueryHandler(IDatabase database, IKeycloakUserClient keycloakUserClient) - : IRequestHandler >> + : IRequestHandler >> { - public async Task> Handle(GetUsersCommand request, CancellationToken cancellationToken) + public async Task> Handle(GetUsersQuery request, CancellationToken cancellationToken) { var pageNumber = request.PageNumber < 1 ? 1 : request.PageNumber; var pageSize = request.PageSize < 1 ? 10 : request.PageSize; @@ -56,4 +56,4 @@ namespace Musify.Application.Users.Queries.GetUsers return Result.Success(response); } } -} \ No newline at end of file +} diff --git a/Infrastructure/Configuration/ConfigurationDependencyInjection.cs b/Infrastructure/Configuration/ConfigurationDependencyInjection.cs index edd3a70..a357138 100644 --- a/Infrastructure/Configuration/ConfigurationDependencyInjection.cs +++ b/Infrastructure/Configuration/ConfigurationDependencyInjection.cs @@ -10,7 +10,8 @@ namespace Musify.Infrastructure.Configuration { serviceDescriptors .AddValidatedConfiguration(configuration, DatabaseConfiguration.SectionName) - .AddValidatedConfiguration(configuration, KeycloakConfiguration.SectionName); + .AddValidatedConfiguration(configuration, KeycloakConfiguration.SectionName) + .AddValidatedConfiguration(configuration, StorageClientConfiguration.SectionName); return serviceDescriptors; } diff --git a/Infrastructure/Configuration/StorageClientConfiguration.cs b/Infrastructure/Configuration/StorageClientConfiguration.cs new file mode 100644 index 0000000..92e3caa --- /dev/null +++ b/Infrastructure/Configuration/StorageClientConfiguration.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; + +namespace Musify.Infrastructure.Configuration +{ + public class StorageClientConfiguration + { + public const string SectionName = "Storage"; + + [Required] + public required string ServiceUrl { get; set; } + + [Required] + public required string AccessKey { get; set; } + + [Required] + public required string SecretAccessKey { get; set; } + + [Required] + public bool ForcePathStyle { get; set; } + + [Required] + public bool UseHttp { get; set; } + } +} diff --git a/Infrastructure/Storage/StorageDependencyInjection.cs b/Infrastructure/Storage/StorageDependencyInjection.cs index 061f13c..f6c71bd 100644 --- a/Infrastructure/Storage/StorageDependencyInjection.cs +++ b/Infrastructure/Storage/StorageDependencyInjection.cs @@ -1,7 +1,7 @@ using Amazon.S3; using Microsoft.Extensions.DependencyInjection; -using Musify.Application.Configuration; using Musify.Application.Contracts.Infrastructure; +using Musify.Infrastructure.Configuration; namespace Musify.Infrastructure.Storage { @@ -11,7 +11,7 @@ namespace Musify.Infrastructure.Storage { serviceDescriptors.AddScoped(serviceProvider => { - var configuration = serviceProvider.GetRequiredService(); + var configuration = serviceProvider.GetRequiredService(); var s3Configuration = new AmazonS3Config { ServiceURL = configuration.ServiceUrl,