diff --git a/src/GuruPR.API/Controllers/v1/AccountController.cs b/src/GuruPR.API/Controllers/v1/AccountController.cs index ea586a8..2a12344 100644 --- a/src/GuruPR.API/Controllers/v1/AccountController.cs +++ b/src/GuruPR.API/Controllers/v1/AccountController.cs @@ -1,10 +1,16 @@ using Asp.Versioning; -using GuruPR.Application.Interfaces.Application; -using GuruPR.Application.Interfaces.Infrastructure; -using GuruPR.Domain.Requests; -using GuruPR.Extensions; -using GuruPR.Infrastructure.Identity.Constants; +using GuruPR.Application.Features.Account.Commands.ConfirmEmail; +using GuruPR.Application.Features.Account.Commands.ExternalLogin.Google.GoogleCallback; +using GuruPR.Application.Features.Account.Commands.ExternalLogin.Google.GoogleLogin; +using GuruPR.Application.Features.Account.Commands.Login; +using GuruPR.Application.Features.Account.Commands.Logout; +using GuruPR.Application.Features.Account.Commands.RefreshToken; +using GuruPR.Application.Features.Account.Commands.Register; +using GuruPR.Application.Features.Users.Dtos; +using GuruPR.Application.Features.Users.Queries.GetCurrentUser; + +using MediatR; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -18,47 +24,37 @@ namespace GuruPR.Controllers.v1; public class AccountController : ControllerBase { private readonly ILogger _logger; - private readonly IAccountService _accountService; - private readonly IExternalAuthService _externalAuthService; + private readonly IMediator _mediator; public AccountController(ILogger logger, - IAccountService accountService, - IExternalAuthService externalAuthService) + IMediator mediator) { _logger = logger; - _accountService = accountService; - _externalAuthService = externalAuthService; + _mediator = mediator; } [HttpPost("register")] [AllowAnonymous] - public async Task RegisterAsync([FromBody] RegisterRequest registerRequest) + public async Task RegisterAsync([FromBody] RegisterCommand registerCommand) { - await _accountService.RegisterAsync(registerRequest); + await _mediator.Send(registerCommand); return Ok("Registration succeeded."); } [HttpPost("login")] [AllowAnonymous] - public async Task LoginAsync([FromBody] LoginRequest loginRequest) + public async Task LoginAsync([FromBody] LoginCommand loginCommand) { - await _accountService.LoginAsync(loginRequest); + await _mediator.Send(loginCommand); return Ok("Login was successful."); } [HttpPost("refresh")] - public async Task RefreshTokenAsync([FromBody] RefreshRequest refreshRequest) + public async Task RefreshTokenAsync([FromBody] RefreshTokenCommand refreshTokenCommand) { - var userId = User.GetClaimValue(JwtClaimTypes.Subject); - - if (userId == null) - { - return Unauthorized("Invalid token or missing subject claim."); - } - - await _accountService.RefreshTokenAsync(userId, refreshRequest.RefreshToken); + await _mediator.Send(refreshTokenCommand); return Ok("Token refresh has succeeded."); } @@ -67,26 +63,16 @@ public async Task RefreshTokenAsync([FromBody] RefreshRequest ref [AllowAnonymous] public async Task ConfirmEmailAsync(string userId, string token) { - try - { - await _accountService.ConfirmEmailAsync(userId, token); - var redirectUrl = await _accountService.GetEmailConfirmationRedirectUrlAsync(true); - - return Redirect(redirectUrl); - } - catch (Exception) - { - var redirectUrl = await _accountService.GetEmailConfirmationRedirectUrlAsync(false); - - return Redirect(redirectUrl); - } + await _mediator.Send(new ConfirmEmailCommand(userId, token)); + + return Ok("Email confirmation succeeded."); } [HttpGet("login/google")] [AllowAnonymous] public async Task GoogleLoginAsync([FromQuery] string? returnUrl) { - var challengeResult = await _externalAuthService.InitiateGoogleLoginAsync(returnUrl, HttpContext); + var challengeResult = await _mediator.Send(new GoogleLoginCommand(returnUrl)); return challengeResult; } @@ -95,16 +81,24 @@ public async Task GoogleLoginAsync([FromQuery] string? returnUrl) [AllowAnonymous] public async Task GoogleLoginCallbackAsync([FromQuery] string returnUrl) { - var redirectUrl = await _externalAuthService.HandleGoogleCallbackAsync(returnUrl, HttpContext); + var redirectUrl = await _mediator.Send(new GoogleCallbackCommand(returnUrl)); return Redirect(redirectUrl); } [HttpPost("logout")] - public async Task LogoutAsync([FromBody] LogoutRequest logoutRequest) + public async Task LogoutAsync([FromBody] LogoutCommand logoutCommand) { - await _accountService.LogoutAsync(logoutRequest.UserId, logoutRequest.RefreshToken); + await _mediator.Send(logoutCommand); return Ok("Logout has succeeded."); } + + [HttpGet("me")] + public async Task> GetCurrentUserAsync() + { + var userDto = await _mediator.Send(new GetCurrentUserQuery()); + + return Ok(userDto); + } } diff --git a/src/GuruPR.API/Controllers/v1/AgentController.cs b/src/GuruPR.API/Controllers/v1/AgentController.cs index 2fab963..fa5b7d0 100644 --- a/src/GuruPR.API/Controllers/v1/AgentController.cs +++ b/src/GuruPR.API/Controllers/v1/AgentController.cs @@ -1,11 +1,14 @@ using Asp.Versioning; -using AutoMapper; +using GuruPR.Application.Common.Models; +using GuruPR.Application.Features.Agents.Commands.CreateAgent; +using GuruPR.Application.Features.Agents.Commands.DeleteAgent; +using GuruPR.Application.Features.Agents.Commands.UpdateAgent; +using GuruPR.Application.Features.Agents.Dtos; +using GuruPR.Application.Features.Agents.Queries.GetAgentById; +using GuruPR.Application.Features.Agents.Queries.GetAgents; -using GuruPR.Application.Dtos.Agent; -using GuruPR.Application.Interfaces.Application; -using GuruPR.Extensions; -using GuruPR.Infrastructure.Identity.Constants; +using MediatR; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -19,64 +22,57 @@ namespace GuruPR.Controllers.v1; public class AgentController : ControllerBase { private readonly ILogger _logger; - private readonly IMapper _mapper; - private readonly IAgentService _agentService; + private readonly IMediator _mediator; public AgentController(ILogger logger, - IMapper mapper, - IAgentService agentService) + IMediator mediator) { _logger = logger; - _mapper = mapper; - _agentService = agentService; + _mediator = mediator; } [HttpGet] - public async Task GetAllAgentsAsync() + public async Task>> GetAllAgentsAsync([FromQuery] GetAgentsQuery getAgentsQuery) { - var agents = await _agentService.GetAllAgentsAsync(); - var agentDtos = _mapper.Map>(agents); + var agents = await _mediator.Send(getAgentsQuery); - return Ok(agentDtos); + return Ok(agents); } [HttpGet("{agentId}", Name = "GetAgentById")] - public async Task GetAgentByIdAsync(string agentId) + public async Task> GetAgentByIdAsync([FromRoute] string agentId) { - var agent = await _agentService.GetAgentByIdAsync(agentId); + var getAgentByIdQuery = new GetAgentByIdQuery(agentId); + var agent = await _mediator.Send(getAgentByIdQuery); return Ok(agent); } [HttpPost] - public async Task CreateAgentAsync([FromBody] CreateAgentRequest createAgentRequest) + public async Task CreateAgentAsync([FromBody] CreateAgentCommand createAgentCommand) { - var userId = User.GetClaimValue(JwtClaimTypes.Name); - if (userId == null) - { - return Unauthorized("Invalid token or missing subject claim."); - } + var agent = await _mediator.Send(createAgentCommand); - var agent = await _agentService.CreateAgentAsync(createAgentRequest, userId); - var agentDto = _mapper.Map(agent); - - return CreatedAtRoute("GetAgentById", new { agentId = agent.Id }, agentDto); + return CreatedAtRoute("GetAgentById", new { agentId = agent.Id }, agent); } [HttpPut("{agentId}")] - public async Task UpdateAgentAsync([FromBody] UpdateAgentRequest updateAgentRequest, string agentId) + public async Task> UpdateAgentAsync([FromRoute] string agentId, [FromBody] UpdateAgentCommand updateAgentCommand) { - var agent = await _agentService.UpdateAgentAsync(agentId, updateAgentRequest); - var agentDto = _mapper.Map(agent); + updateAgentCommand.Id = agentId; + + var agent = await _mediator.Send(updateAgentCommand); - return Ok(agentDto); + return Ok(agent); } [HttpDelete("{agentId}")] - public async Task DeleteAgentAsync(string agentId) + public async Task DeleteAgentAsync([FromRoute] string agentId) { - var result = await _agentService.DeleteAgentAsync(agentId); + var deleteAgentCommand = new DeleteAgentCommand(agentId); + + await _mediator.Send(deleteAgentCommand); - return result ? NoContent() : NotFound(); + return NoContent(); } } diff --git a/src/GuruPR.API/Controllers/v1/ConversationController.cs b/src/GuruPR.API/Controllers/v1/ConversationController.cs index b26dee4..64be77d 100644 --- a/src/GuruPR.API/Controllers/v1/ConversationController.cs +++ b/src/GuruPR.API/Controllers/v1/ConversationController.cs @@ -1,12 +1,17 @@ using Asp.Versioning; -using AutoMapper; - -using GuruPR.Application.Dtos.Conversation; -using GuruPR.Application.Interfaces.Application; -using GuruPR.Domain.Requests; -using GuruPR.Extensions; -using GuruPR.Infrastructure.Identity.Constants; +using GuruPR.Application.Common.Models; +using GuruPR.Application.Features.Conversations.Commands.ClearConversation; +using GuruPR.Application.Features.Conversations.Commands.CreateCompletion; +using GuruPR.Application.Features.Conversations.Commands.CreateConversation; +using GuruPR.Application.Features.Conversations.Commands.DeleteConversation; +using GuruPR.Application.Features.Conversations.Commands.UpdateConversation; +using GuruPR.Application.Features.Conversations.Dtos; +using GuruPR.Application.Features.Conversations.Queries.GetConversationById; +using GuruPR.Application.Features.Conversations.Queries.GetConversationsByUserId; +using GuruPR.Application.Features.Conversations.Queries.GetMessages; + +using MediatR; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -20,108 +25,78 @@ namespace GuruPR.Controllers.v1; public class ConversationController : ControllerBase { private readonly ILogger _logger; - private readonly IMapper _mapper; - private readonly IConversationService _conversationService; - private readonly IMessageService _messageService; - - public ConversationController(ILogger logger, - IMapper mapper, - IConversationService conversationService, - IMessageService messageService) + private readonly IMediator _mediator; + + public ConversationController(ILogger logger, IMediator mediator) { _logger = logger; - _mapper = mapper; - _conversationService = conversationService; - _messageService = messageService; + _mediator = mediator; } [HttpGet] - public async Task GetAllConversationsAsync() + public async Task>> GetAllConversationsByUserIdAsync([FromQuery] GetConversationsByUserIdQuery getConversationsByUserIdQuery) { - var userId = User.GetClaimValue(JwtClaimTypes.Subject); - if (string.IsNullOrEmpty(userId)) - { - return Unauthorized("Invalid token or missing subject claim."); - } + var conversations = await _mediator.Send(getConversationsByUserIdQuery); - var conversations = await _conversationService.GetAllConversationsByUserIdAsync(userId); return Ok(conversations); } [HttpGet("{conversationId}", Name = "GetConversationById")] - public async Task GetConversationByIdAsync(string conversationId) + public async Task> GetConversationByIdAsync(string conversationId) { - var userId = User.GetClaimValue(JwtClaimTypes.Subject); - if (string.IsNullOrEmpty(userId)) - { - return Unauthorized("Invalid token or missing subject claim."); - } + var conversation = await _mediator.Send(new GetConversationByIdQuery(conversationId)); - var conversation = await _conversationService.GetConversationByIdAsync(conversationId, userId); return Ok(conversation); } [HttpPost] - public async Task CreateConversationAsync(CreateConversationRequest createConversationRequest) + public async Task> CreateConversationAsync(CreateConversationCommand createConversationCommand) { - var userId = User.GetClaimValue(JwtClaimTypes.Subject); - if (string.IsNullOrEmpty(userId)) - { - return Unauthorized("Invalid token or missing subject claim."); - } - - var conversation = await _conversationService.CreateConversationAsync(createConversationRequest, userId); - var conversationDto = _mapper.Map(conversation); + var conversation = await _mediator.Send(createConversationCommand); - return CreatedAtRoute("GetConversationById", new { conversationId = conversation.Id }, conversationDto); + return CreatedAtRoute("GetConversationById", new { conversationId = conversation.Id }, conversation); } [HttpPut("{conversationId}")] - public async Task UpdateConversationAsync(string conversationId, [FromBody] UpdateConversationRequest updateConversationRequest) + public async Task> UpdateConversationAsync(string conversationId, [FromBody] UpdateConversationCommand updateConversationCommand) { - var userId = User.GetClaimValue(JwtClaimTypes.Subject); - if (string.IsNullOrEmpty(userId)) - { - return Unauthorized("Invalid token or missing subject claim."); - } + updateConversationCommand.Id = conversationId; - var conversation = await _conversationService.UpdateConversationAsync(conversationId, userId, updateConversationRequest); - var conversationDto = _mapper.Map(conversation); + var conversation = await _mediator.Send(updateConversationCommand); - return Ok(conversationDto); + return Ok(conversation); } [HttpDelete("{conversationId}")] - public async Task DeleteConversationAsync([FromQuery] string conversationId) + public async Task DeleteConversationAsync(string conversationId) { - var result = await _conversationService.DeleteConversationAsync(conversationId); + await _mediator.Send(new DeleteConversationCommand(conversationId)); - return result ? NoContent() : NotFound(); + return NoContent(); } [HttpGet("{conversationId}/messages")] - public async Task GetMessagesByConversationIdAsync(string conversationId) + public async Task>> GetMessagesByConversationIdAsync(string conversationId) { - var userId = User.GetClaimValue(JwtClaimTypes.Subject); - if (string.IsNullOrEmpty(userId)) - { - return Unauthorized("Invalid token or missing subject claim."); - } + var messages = await _mediator.Send(new GetMessagesQuery(conversationId)); - var messages = await _messageService.GetMessagesByConversationIdAsync(conversationId, userId); return Ok(messages); } + [HttpGet("{conversationId}/messages/clear")] + public async Task ClearConversationAsync(string conversationId) + { + await _mediator.Send(new ClearConversationCommand(conversationId)); + + return NoContent(); + } + [HttpPost("{conversationId}/completions")] - public async Task CreateCompletionAsync(string conversationId, [FromBody] AgentExecutionRequest agentExecutionRequest) + public async Task> CreateCompletionAsync(string conversationId, [FromBody] CreateCompletionCommand createCompletionCommand) { - var userId = User.GetClaimValue(JwtClaimTypes.Subject); - if (string.IsNullOrEmpty(userId)) - { - return Unauthorized("Invalid token or missing subject claim."); - } + createCompletionCommand.Id = conversationId; - await _conversationService.RunAgentWorkflowAsync(agentExecutionRequest, userId); + var message = await _mediator.Send(createCompletionCommand); // ToDo: Change to Accepted when streaming is implemented return NoContent(); diff --git a/src/GuruPR.API/Controllers/v1/HealthController.cs b/src/GuruPR.API/Controllers/v1/HealthController.cs new file mode 100644 index 0000000..0d0bc2e --- /dev/null +++ b/src/GuruPR.API/Controllers/v1/HealthController.cs @@ -0,0 +1,17 @@ +using Asp.Versioning; + +using Microsoft.AspNetCore.Mvc; + +namespace GuruPR.Controllers.v1; + +[ApiController] +[ApiVersion(1.0)] +[Route("api/v{version:apiVersion}/health")] +public class HealthController : ControllerBase +{ + [HttpGet] + public IActionResult Health() + { + return Ok("Healthy"); + } +} diff --git a/src/GuruPR.API/Controllers/v1/ProviderConnectionController.cs b/src/GuruPR.API/Controllers/v1/ProviderConnectionController.cs index 167ab3f..9b75511 100644 --- a/src/GuruPR.API/Controllers/v1/ProviderConnectionController.cs +++ b/src/GuruPR.API/Controllers/v1/ProviderConnectionController.cs @@ -1,77 +1,78 @@ using Asp.Versioning; -using AutoMapper; +using GuruPR.Application.Features.ProviderConnections.Commands.CreateProviderConnection; +using GuruPR.Application.Features.ProviderConnections.Commands.DeleteProviderConnection; +using GuruPR.Application.Features.ProviderConnections.Commands.UpdateProviderConnection; +using GuruPR.Application.Features.ProviderConnections.Dtos; +using GuruPR.Application.Features.ProviderConnections.Queries.GetProviderConnectionById; +using GuruPR.Application.Features.ProviderConnections.Queries.GetProviderConnections; -using GuruPR.Application.Dtos.OAuth.ProviderConnection; -using GuruPR.Application.Interfaces.Application; +using MediatR; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace GuruPR.Controllers.v1; -[Authorize] +[Authorize(Roles = "Admin")] [ApiController] [ApiVersion(1.0)] [Route("api/v{version:apiVersion}/providers/{providerId}/provider-connections")] public class ProviderConnectionController : ControllerBase { private readonly ILogger _logger; - private readonly IMapper _mapper; - private readonly IProviderConnectionService _providerConnectionService; + private readonly IMediator _mediator; public ProviderConnectionController(ILogger logger, - IMapper mapper, - IProviderConnectionService providerConnectionService) + IMediator mediator) { _logger = logger; - _mapper = mapper; - _providerConnectionService = providerConnectionService; + _mediator = mediator; } [HttpGet] - public async Task GetConnectionsByProviderAsync(string providerId) + public async Task>> GetProviderConnectionsByProviderAsync(string providerId) { - var providerConnections = await _providerConnectionService.GetConnectionsByProviderIdAsync(providerId); - var providerConnectionsDtos = _mapper.Map>(providerConnections); + var providerConnections = await _mediator.Send(new GetProviderConnectionsQuery(providerId)); - return Ok(providerConnectionsDtos); + return Ok(providerConnections); } [HttpGet("{providerConnectionId}", Name = "GetProviderConnectionById")] - public async Task GetProviderConnectionByIdAsync(string providerId, string providerConnectionId) + public async Task> GetProviderConnectionByIdAsync(string providerConnectionId) { - var providerConnection = await _providerConnectionService.GetProviderConnectionByIdAsync(providerId, providerConnectionId); - var providerConnectionDto = _mapper.Map(providerConnection); + var providerConnections = await _mediator.Send(new GetProviderConnectionByIdQuery(providerConnectionId)); - return Ok(providerConnectionDto); + return Ok(providerConnections); } [HttpPost] - public async Task AddProviderConnectionToProviderAsync(string providerId, [FromBody] CreateProviderConnectionRequest createProviderConnectionRequest) + public async Task AddProviderConnectionToProviderAsync(string providerId, [FromBody] CreateProviderConnectionCommand createProviderConnectionCommand) { - var providerConnection = await _providerConnectionService.AddProviderConnectionToProviderAsync(providerId, createProviderConnectionRequest); - var providerConnectionDto = _mapper.Map(providerConnection); + createProviderConnectionCommand.ProviderId = providerId; + var providerConnection = await _mediator.Send(createProviderConnectionCommand); return CreatedAtRoute("GetProviderConnectionById", - new { providerId, providerConnectionId = providerConnectionDto.Id }, + new { providerId, providerConnectionId = providerConnection.Id }, providerConnection); } [HttpPut("{providerConnectionId}")] - public async Task UpdateProviderConnectionAsync(string providerId, string providerConnectionId, [FromBody] UpdateProviderConnectionRequest updateProviderConnectionRequest) + public async Task UpdateProviderConnectionAsync(string providerConnectionId, [FromBody] UpdateProviderConnectionCommand updateProviderConnectionCommand) { - // Note: Update functionality is not implemented in the service layer as per the current design. - // This endpoint is a placeholder for future implementation. - return StatusCode(501, "Update functionality is not implemented."); + updateProviderConnectionCommand.Id = providerConnectionId; + + var providerConnection = await _mediator.Send(updateProviderConnectionCommand); + + return Ok(providerConnection); } [HttpDelete("{providerConnectionId}")] - public async Task DeleteProviderConnectionAsync(string providerId, string providerConnectionId) + public async Task DeleteProviderConnectionAsync(string providerConnectionId) { - var result = await _providerConnectionService.DeleteProviderConnectionAsync(providerId, providerConnectionId); + await _mediator.Send(new DeleteProviderConnectionCommand(providerConnectionId)); - return result ? NoContent() : NotFound(); + return NoContent(); } } diff --git a/src/GuruPR.API/Controllers/v1/ProviderController.cs b/src/GuruPR.API/Controllers/v1/ProviderController.cs index 5d72683..83730a4 100644 --- a/src/GuruPR.API/Controllers/v1/ProviderController.cs +++ b/src/GuruPR.API/Controllers/v1/ProviderController.cs @@ -1,73 +1,73 @@ using Asp.Versioning; -using AutoMapper; +using GuruPR.Application.Features.Providers.Commands.CreateProvider; +using GuruPR.Application.Features.Providers.Commands.DeleteProvider; +using GuruPR.Application.Features.Providers.Commands.UpdateProvider; +using GuruPR.Application.Features.Providers.Dtos; +using GuruPR.Application.Features.Providers.Queries.GetProviderById; +using GuruPR.Application.Features.Providers.Queries.GetProviders; -using GuruPR.Application.Dtos.OAuth.Provider; -using GuruPR.Application.Interfaces.Application; +using MediatR; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace GuruPR.Controllers.v1; -[Authorize] +[Authorize(Roles = "Admin")] [ApiController] [ApiVersion(1.0)] [Route("api/v{version:apiVersion}/providers")] public class ProviderController : ControllerBase { private readonly ILogger _logger; - private readonly IMapper _mapper; - private readonly IProviderService _providerService; + private readonly IMediator _mediator; - public ProviderController(ILogger logger, IMapper mapper, IProviderService providerService) + public ProviderController(ILogger logger, IMediator mediator) { _logger = logger; - _mapper = mapper; - _providerService = providerService; + _mediator = mediator; } [HttpGet] - public async Task GetAllProvidersAsync() + public async Task>> GetAllProvidersAsync() { - var providers = await _providerService.GetAllProvidersAsync(); - var providerDtos = _mapper.Map>(providers); + var providers = await _mediator.Send(new GetProvidersQuery()); - return Ok(providerDtos); + return Ok(providers); } [HttpGet("{providerId}", Name = "GetProviderById")] - public async Task GetProviderByIdAsync(string providerId) + public async Task> GetProviderByIdAsync(string providerId) { - var provider = await _providerService.GetProviderByIdAsync(providerId); - var providerDto = _mapper.Map(provider); + var provider = await _mediator.Send(new GetProviderByIdQuery(providerId)); - return provider == null ? NotFound() : Ok(providerDto); + return provider == null ? NotFound() : Ok(provider); } [HttpPost] - public async Task CreateProviderAsync([FromBody] CreateProviderRequest createProviderRequest) + public async Task> CreateProviderAsync([FromBody] CreateProviderCommand createProviderCommand) { - var provider = await _providerService.CreateProviderAsync(createProviderRequest); - var providerDto = _mapper.Map(provider); + var provider = await _mediator.Send(createProviderCommand); - return CreatedAtRoute("GetProviderById", new { providerId = providerDto.Id }, providerDto); + return CreatedAtRoute("GetProviderById", new { providerId = provider.Id }, provider); } [HttpPut("{providerId}")] - public async Task UpdateProviderAsync(string providerId, [FromBody] UpdateProviderRequest updateProviderRequest) + public async Task> UpdateProviderAsync(string providerId, [FromBody] UpdateProviderCommand updateProviderCommand) { - var provider = await _providerService.UpdateProviderAsync(providerId, updateProviderRequest); - var providerDto = _mapper.Map(provider); + updateProviderCommand.Id = providerId; - return provider == null ? NotFound() : Ok(providerDto); + var provider = await _mediator.Send(updateProviderCommand); + + return Ok(provider); } [HttpDelete("{providerId}")] public async Task DeleteProviderAsync(string providerId) { - var result = await _providerService.DeleteProviderAsync(providerId); + await _mediator.Send(new DeleteProviderCommand(providerId)); - return result ? NotFound() : NoContent(); + return NoContent(); } } diff --git a/src/GuruPR.API/Extensions/ServiceCollectionExtensions.cs b/src/GuruPR.API/Extensions/ServiceCollectionExtensions.cs index 53690ff..adfaf92 100644 --- a/src/GuruPR.API/Extensions/ServiceCollectionExtensions.cs +++ b/src/GuruPR.API/Extensions/ServiceCollectionExtensions.cs @@ -1,12 +1,15 @@ using Asp.Versioning; -using GuruPR.Application.Extensions; -using GuruPR.Application.Settings; -using GuruPR.Application.Settings.Email; -using GuruPR.Application.Settings.FrontEnd; -using GuruPR.Application.Settings.Security; +using GuruPR.Application.Common.Extensions; +using GuruPR.Application.Common.Interfaces.Presentation; +using GuruPR.Application.Common.Settings; +using GuruPR.Application.Common.Settings.Authentication; +using GuruPR.Application.Common.Settings.Email; +using GuruPR.Application.Common.Settings.FrontEnd; +using GuruPR.Application.Common.Settings.Security; using GuruPR.Infrastructure.Extensions; using GuruPR.Persistence.Extensions; +using GuruPR.Services; namespace GuruPR.Extensions; @@ -32,11 +35,14 @@ public static IServiceCollection ConfigureAllApplicationServices(this IServiceCo } ); + services.AddHttpContextAccessor(); + services.ConfigureCors(); services.ConfigureLogging(); services.ConfigureSignalR(); services.ConfigureSettings(configuration); + services.ConfigurePresentationServices(); services.ConfigureApplicationServices(); services.ConfigurePersistence(configuration); services.ConfigureInfrastructure(configuration); @@ -59,9 +65,10 @@ private static void ConfigureCors(this IServiceCollection services) services.AddCors( options => options.AddPolicy( "CorsPolicy", - builder => builder.AllowAnyOrigin() + builder => builder.WithOrigins("http://localhost:5173") .AllowAnyMethod() - .AllowAnyHeader()) + .AllowAnyHeader() + .AllowCredentials()) ); } @@ -70,9 +77,11 @@ private static void ConfigureSettings(this IServiceCollection services, IConfigu services.AddSettings(configuration); services.AddSettings(configuration); services.AddSettings(configuration); + services.AddSettings(configuration); services.AddSettings(configuration); services.AddSettings(configuration); services.AddSettings(configuration); + services.AddSettings(configuration); } private static void AddSettings(this IServiceCollection services, IConfiguration configuration) where T : class, ISettings @@ -90,6 +99,11 @@ private static void ConfigureSignalR(this IServiceCollection services) services.AddSignalR(); } + private static void ConfigurePresentationServices(this IServiceCollection services) + { + services.AddScoped(); + } + private static void ConfigureApplicationServices(this IServiceCollection services) { services.AddApplicationServices(); diff --git a/src/GuruPR.API/Extensions/WebApplicationExtensions.cs b/src/GuruPR.API/Extensions/WebApplicationExtensions.cs index 736d951..e885e90 100644 --- a/src/GuruPR.API/Extensions/WebApplicationExtensions.cs +++ b/src/GuruPR.API/Extensions/WebApplicationExtensions.cs @@ -13,6 +13,8 @@ public static WebApplication ConfigureMiddlewarePipeline(this WebApplication app app.UseMiddleware(); + app.UseCors("CorsPolicy"); + app.UseRouting(); app.UseAuthentication(); diff --git a/src/GuruPR.API/GuruPR.API.csproj b/src/GuruPR.API/GuruPR.API.csproj index 3257f3c..8a87e9f 100644 --- a/src/GuruPR.API/GuruPR.API.csproj +++ b/src/GuruPR.API/GuruPR.API.csproj @@ -11,6 +11,7 @@ + diff --git a/src/GuruPR.API/Middlewares/ExceptionHandlingMiddleware.cs b/src/GuruPR.API/Middlewares/ExceptionHandlingMiddleware.cs index 26e32a2..017446b 100644 --- a/src/GuruPR.API/Middlewares/ExceptionHandlingMiddleware.cs +++ b/src/GuruPR.API/Middlewares/ExceptionHandlingMiddleware.cs @@ -1,6 +1,7 @@ -using GuruPR.Application.Exceptions; +using GuruPR.Application.Common.Exceptions; +using GuruPR.Application.Common.Exceptions.Interfaces; using GuruPR.Application.Exceptions.Account; -using GuruPR.Application.Exceptions.Interfaces; +using GuruPR.Application.Features.Account.Exceptions; using Microsoft.AspNetCore.Mvc; @@ -43,10 +44,12 @@ private async Task HandleExceptionAsync(HttpContext context, Exception exception var (statusCode, title) = exception switch { // Client Errors - ArgumentNullException => (StatusCodes.Status400BadRequest, "A required argument was missing"), InvalidReturnUrlException => (StatusCodes.Status400BadRequest, "Invalid Return URL"), - RegistrationFailedException => (StatusCodes.Status400BadRequest, "User Registration Failed"), UntrustedReturnUrlException => (StatusCodes.Status400BadRequest, "Untrusted Return URL"), + EmailConfirmationException => (StatusCodes.Status400BadRequest, "Email Confirmation Failed"), + RegistrationFailedException => (StatusCodes.Status400BadRequest, "User Registration Failed"), + ArgumentNullException => (StatusCodes.Status400BadRequest, "A required argument was missing"), + ValidationExceptionBase => (StatusCodes.Status400BadRequest, "Validation Failed"), LoginFailedException => (StatusCodes.Status401Unauthorized, "Login Failed"), RefreshTokenException => (StatusCodes.Status401Unauthorized, "Invalid Refresh Token"), @@ -57,6 +60,7 @@ private async Task HandleExceptionAsync(HttpContext context, Exception exception UserAlreadyExistsException => (StatusCodes.Status409Conflict, "User Already Exists"), // Server Errors + InvalidOperationException => (StatusCodes.Status500InternalServerError, "Invalid Operation"), MissingAllowedOriginsException => (StatusCodes.Status500InternalServerError, "Missing Allowed Origins Configuration"), UserRoleOperationFailedException => (StatusCodes.Status500InternalServerError, "User Role Operation Failed"), OperationFailedException => (StatusCodes.Status500InternalServerError, "Operation Failed"), diff --git a/src/GuruPR.API/Services/CurrentUserService.cs b/src/GuruPR.API/Services/CurrentUserService.cs new file mode 100644 index 0000000..0157adf --- /dev/null +++ b/src/GuruPR.API/Services/CurrentUserService.cs @@ -0,0 +1,29 @@ +using GuruPR.Application.Common.Interfaces.Presentation; +using GuruPR.Infrastructure.Identity.Constants; + +namespace GuruPR.Services; + +public sealed class CurrentUserService : ICurrentUserService +{ + private readonly IHttpContextAccessor _httpContextAccessor; + + public CurrentUserService(IHttpContextAccessor httpContextAccessor) + { + _httpContextAccessor = httpContextAccessor; + } + + public string? UserId + { + get + { + var principal = _httpContextAccessor.HttpContext?.User; + + if (principal == null || !principal.Identity?.IsAuthenticated == true) + { + return null; + } + + return principal.FindFirst(JwtClaimTypes.Subject)?.Value; + } + } +} diff --git a/src/GuruPR.API/appsettings.json b/src/GuruPR.API/appsettings.json index 9b6c01c..4731447 100644 --- a/src/GuruPR.API/appsettings.json +++ b/src/GuruPR.API/appsettings.json @@ -35,6 +35,9 @@ "Audience": "", "ExpirationTimeInMinutes": 0 }, + "RefreshToken": { + "ExpirationTimeInDays": 7 + }, "EmailValidation": { "AllowedDomains": [ "" ] }, diff --git a/src/GuruPR.Application/Common/Behaviors/OwnershipValidatorBehavior.cs b/src/GuruPR.Application/Common/Behaviors/OwnershipValidatorBehavior.cs new file mode 100644 index 0000000..a5e12c5 --- /dev/null +++ b/src/GuruPR.Application/Common/Behaviors/OwnershipValidatorBehavior.cs @@ -0,0 +1,41 @@ +using GuruPR.Application.Common.Interfaces.Persistence; +using GuruPR.Application.Common.Markers.Interfaces; + +using MediatR; + +namespace GuruPR.Application.Common.Behaviors; + +public class OwnershipValidatorBehavior : IPipelineBehavior + where TRequest : IRequest +{ + private readonly IServiceProvider _provider; + + public OwnershipValidatorBehavior(IServiceProvider provider) { _provider = provider; } + + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + // Check if request implements IOwnedEntityRequest at runtime + var ownedInterface = request.GetType() + .GetInterfaces() + .FirstOrDefault(@interface => @interface.IsGenericType && @interface.GetGenericTypeDefinition() == typeof(IOwnedEntityRequest<>)); + + if (ownedInterface != null) + { + var entityType = ownedInterface.GetGenericArguments()[0]; + + // Resolve the repository for that TEntity + var repoType = typeof(IOwnedGenericRepository<>).MakeGenericType(entityType); + dynamic repo = _provider.GetService(repoType) ?? throw new InvalidOperationException($"No repository for {entityType.Name}"); + + // Perform the ownership check + string id = ((dynamic)request).Id; + string userId = ((dynamic)request).UserId; + var entity = await repo.GetByIdAndOwnerAsync(id, userId); + if (entity == null) + { + throw new UnauthorizedAccessException($"No access or not found for ID {id}"); + } + } + return await next(); + } +} diff --git a/src/GuruPR.Application/Common/Behaviors/UserContextEnrichmentBehavior.cs b/src/GuruPR.Application/Common/Behaviors/UserContextEnrichmentBehavior.cs new file mode 100644 index 0000000..b758665 --- /dev/null +++ b/src/GuruPR.Application/Common/Behaviors/UserContextEnrichmentBehavior.cs @@ -0,0 +1,31 @@ +using GuruPR.Application.Common.Interfaces.Presentation; +using GuruPR.Application.Common.Markers.Interfaces; + +using MediatR; + +namespace GuruPR.Application.Common.Behaviors; + +public sealed class UserContextEnrichmentBehavior : IPipelineBehavior + where TRequest : IUserContextCommand +{ + private readonly ICurrentUserService _currentUserService; + + public UserContextEnrichmentBehavior(ICurrentUserService currentUserService) + { + _currentUserService = currentUserService; + } + + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + var userId = _currentUserService.UserId; + + if (string.IsNullOrWhiteSpace(userId)) + { + throw new UnauthorizedAccessException("Authenticated user id is required but was not found."); + } + + request.UserId = userId; + + return await next(); + } +} diff --git a/src/GuruPR.Application/Common/Constants/Constants.cs b/src/GuruPR.Application/Common/Constants/Constants.cs new file mode 100644 index 0000000..1da0baa --- /dev/null +++ b/src/GuruPR.Application/Common/Constants/Constants.cs @@ -0,0 +1,5 @@ +namespace GuruPR.Application.Common.Constants; +public static class Constants +{ + public const string ConfirmationEmailTitle = "Confirm Your GuruPR Account ✨"; +} diff --git a/src/GuruPR.Application/Exceptions/Interfaces/IValidationException.cs b/src/GuruPR.Application/Common/Exceptions/Interfaces/IValidationException.cs similarity index 64% rename from src/GuruPR.Application/Exceptions/Interfaces/IValidationException.cs rename to src/GuruPR.Application/Common/Exceptions/Interfaces/IValidationException.cs index ff765f6..75da6b9 100644 --- a/src/GuruPR.Application/Exceptions/Interfaces/IValidationException.cs +++ b/src/GuruPR.Application/Common/Exceptions/Interfaces/IValidationException.cs @@ -1,4 +1,4 @@ -namespace GuruPR.Application.Exceptions.Interfaces; +namespace GuruPR.Application.Common.Exceptions.Interfaces; public interface IValidationException { diff --git a/src/GuruPR.Application/Exceptions/NotFoundException.cs b/src/GuruPR.Application/Common/Exceptions/NotFoundException.cs similarity index 57% rename from src/GuruPR.Application/Exceptions/NotFoundException.cs rename to src/GuruPR.Application/Common/Exceptions/NotFoundException.cs index 1da03d1..c75793d 100644 --- a/src/GuruPR.Application/Exceptions/NotFoundException.cs +++ b/src/GuruPR.Application/Common/Exceptions/NotFoundException.cs @@ -1,3 +1,3 @@ -namespace GuruPR.Application.Exceptions; +namespace GuruPR.Application.Common.Exceptions; public class NotFoundException(string message) : Exception(message); diff --git a/src/GuruPR.Application/Exceptions/OperationFailedException.cs b/src/GuruPR.Application/Common/Exceptions/OperationFailedException.cs similarity index 60% rename from src/GuruPR.Application/Exceptions/OperationFailedException.cs rename to src/GuruPR.Application/Common/Exceptions/OperationFailedException.cs index 1ea19c4..c87733b 100644 --- a/src/GuruPR.Application/Exceptions/OperationFailedException.cs +++ b/src/GuruPR.Application/Common/Exceptions/OperationFailedException.cs @@ -1,3 +1,3 @@ -namespace GuruPR.Application.Exceptions; +namespace GuruPR.Application.Common.Exceptions; public class OperationFailedException(string message) : Exception(message); diff --git a/src/GuruPR.Application/Common/Exceptions/ValidationExceptionBase.cs b/src/GuruPR.Application/Common/Exceptions/ValidationExceptionBase.cs new file mode 100644 index 0000000..e00a9d6 --- /dev/null +++ b/src/GuruPR.Application/Common/Exceptions/ValidationExceptionBase.cs @@ -0,0 +1,18 @@ +using GuruPR.Application.Common.Exceptions.Interfaces; + +namespace GuruPR.Application.Common.Exceptions; + +public class ValidationExceptionBase : Exception, IValidationException +{ + public IReadOnlyDictionary> Errors { get; } + + public ValidationExceptionBase(string message) : base(message) + { + Errors = new Dictionary>(); + } + + public ValidationExceptionBase(string message, IReadOnlyDictionary> errors) : base(message) + { + Errors = errors ?? new Dictionary>(); + } +} diff --git a/src/GuruPR.Application/Common/Extensions/IdentityErrorsExtensions.cs b/src/GuruPR.Application/Common/Extensions/IdentityErrorsExtensions.cs new file mode 100644 index 0000000..42c5a0b --- /dev/null +++ b/src/GuruPR.Application/Common/Extensions/IdentityErrorsExtensions.cs @@ -0,0 +1,52 @@ +using System.Collections.ObjectModel; + +using GuruPR.Application.Common.Exceptions; + +using Microsoft.AspNetCore.Identity; + +namespace GuruPR.Application.Common.Extensions; + +public static class IdentityErrorsExtensions +{ + public static void ThrowValidationException(this IEnumerable errors, string message) where TException : ValidationExceptionBase + { + var errorGroups = errors.GroupBy(error => GetErrorCategory(error.Code)) + .ToDictionary( + group => group.Key, + group => group.Select(error => error.Description).ToList() + ); + + var readonlyErrors = new ReadOnlyDictionary>(errorGroups); + var exception = Activator.CreateInstance(typeof(TException), message, readonlyErrors) as TException + ?? throw new InvalidOperationException( + $"Type {typeof(TException).Name} must have a constructor (string message, IReadOnlyDictionary> errors)." + ); + + throw exception; + } + + private static string GetErrorCategory(string errorCode) + { + if (errorCode.StartsWith("Password", StringComparison.OrdinalIgnoreCase)) + { + return "Password"; + } + + if (errorCode.Contains("Email", StringComparison.OrdinalIgnoreCase)) + { + return "Email"; + } + + if (errorCode.StartsWith("FirstName", StringComparison.OrdinalIgnoreCase)) + { + return "FirstName"; + } + + if (errorCode.StartsWith("LastName", StringComparison.OrdinalIgnoreCase)) + { + return "LastName"; + } + + return errorCode; + } +} diff --git a/src/GuruPR.Application/Common/Extensions/QuerableExtensions.cs b/src/GuruPR.Application/Common/Extensions/QuerableExtensions.cs new file mode 100644 index 0000000..beb984b --- /dev/null +++ b/src/GuruPR.Application/Common/Extensions/QuerableExtensions.cs @@ -0,0 +1,14 @@ +using GuruPR.Application.Common.Models; + +namespace GuruPR.Application.Common.Extensions; + +public static class QuerableExtensions +{ + public static Task> ToPaginatedListAsync(this IQueryable source, + int pageIndex, + int pageSize, + CancellationToken cancellationToken = default) + { + return PaginatedList.CreateAsync(source, pageIndex, pageSize, cancellationToken); + } +} diff --git a/src/GuruPR.Application/Common/Extensions/ServiceExtensions.cs b/src/GuruPR.Application/Common/Extensions/ServiceExtensions.cs new file mode 100644 index 0000000..fdefbc0 --- /dev/null +++ b/src/GuruPR.Application/Common/Extensions/ServiceExtensions.cs @@ -0,0 +1,114 @@ +using FluentValidation; + +using GuruPR.Application.Common.Behaviors; +using GuruPR.Application.Common.Factories; +using GuruPR.Application.Common.Interfaces.Application; +using GuruPR.Application.Common.Markers; +using GuruPR.Application.Common.Services; +using GuruPR.Application.Features.Account.Commands.ConfirmEmail; +using GuruPR.Application.Features.Account.Commands.ExternalLogin.Google.GoogleCallback; +using GuruPR.Application.Features.Account.Commands.ExternalLogin.Google.GoogleLogin; +using GuruPR.Application.Features.Account.Commands.Login; +using GuruPR.Application.Features.Account.Commands.Logout; +using GuruPR.Application.Features.Account.Commands.RefreshToken; +using GuruPR.Application.Features.Account.Commands.Register; +using GuruPR.Application.Features.Account.Services; +using GuruPR.Application.Features.Agents.Commands.CreateAgent; +using GuruPR.Application.Features.Agents.Commands.UpdateAgent; +using GuruPR.Application.Features.Agents.Profiles; +using GuruPR.Application.Features.Conversations.Commands.CreateConversation; +using GuruPR.Application.Features.Conversations.Commands.UpdateConversation; +using GuruPR.Application.Features.Conversations.Profiles; +using GuruPR.Application.Features.ProviderConnections.Commands.CreateProviderConnection; +using GuruPR.Application.Features.ProviderConnections.Commands.DeleteProviderConnection; +using GuruPR.Application.Features.ProviderConnections.Commands.UpdateProviderConnection; +using GuruPR.Application.Features.ProviderConnections.Profiles; +using GuruPR.Application.Features.ProviderConnections.Queries.GetProviderConnectionById; +using GuruPR.Application.Features.ProviderConnections.Queries.GetProviderConnections; +using GuruPR.Application.Features.Providers.Commands.CreateProvider; +using GuruPR.Application.Features.Providers.Commands.DeleteProvider; +using GuruPR.Application.Features.Providers.Commands.UpdateProvider; +using GuruPR.Application.Features.Providers.Profiles; +using GuruPR.Application.Features.Providers.Queries.GetProviderById; +using GuruPR.Application.Features.Providers.Queries.GetProviders; +using GuruPR.Application.Features.Users.Profiles; +using GuruPR.Application.Features.Users.Services; + +using MediatR; + +using Microsoft.Extensions.DependencyInjection; + +namespace GuruPR.Application.Common.Extensions; + +public static class ServiceExtensions +{ + public static void AddApplicationServices(this IServiceCollection services) + { + services.AddAutoMapper(configuration => + { + configuration.AllowNullCollections = true; + + configuration.AddProfile(); + configuration.AddProfile(); + configuration.AddProfile(); + configuration.AddProfile(); + configuration.AddProfile(); + configuration.AddProfile(); + }); + + services.AddMediatR(configuration => + { + configuration.RegisterServicesFromAssembly(typeof(ApplicationMarker).Assembly); + }); + + // Register Pipeline Behaviors + services.AddScoped(typeof(IPipelineBehavior<,>), typeof(UserContextEnrichmentBehavior<,>)); + services.AddScoped(typeof(IPipelineBehavior<,>), typeof(OwnershipValidatorBehavior<,>)); + + services.AddFluentValidators(); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddScoped(); + } + + private static void AddFluentValidators(this IServiceCollection services) + { + // Agent Validators + services.AddScoped, CreateAgentValidator>(); + services.AddScoped, UpdateAgentValidator>(); + + // Conversation Validators + services.AddScoped, CreateConversationValidator>(); + services.AddScoped, UpdateConversationValidator>(); + + // Account Validators + services.AddScoped, RegisterValidator>(); + services.AddScoped, LoginValidator>(); + services.AddScoped, LogoutValidator>(); + services.AddScoped, RefreshTokenValidator>(); + services.AddScoped, ConfirmEmailValidator>(); + services.AddScoped, GoogleCallbackValidator>(); + services.AddScoped, GoogleLoginValidator>(); + + // Provider Connection Validators + services.AddScoped, CreateProviderConnectionValidator>(); + services.AddScoped, UpdateProviderConnectionValidator>(); + services.AddScoped, DeleteProviderConnectionValidator>(); + + services.AddScoped, GetProviderConnectionByIdValidator>(); + services.AddScoped, GetProviderConnectionsValidator>(); + + // Provider Validators + services.AddScoped, CreateProviderValidator>(); + services.AddScoped, UpdateProviderValidator>(); + services.AddScoped, DeleteProviderValidator>(); + + services.AddScoped, GetProviderByIdValidator>(); + services.AddScoped, GetProvidersValidator>(); + } +} diff --git a/src/GuruPR.Application/Common/Extensions/Validation/OwnershipValidationExtensions.cs b/src/GuruPR.Application/Common/Extensions/Validation/OwnershipValidationExtensions.cs new file mode 100644 index 0000000..dcec552 --- /dev/null +++ b/src/GuruPR.Application/Common/Extensions/Validation/OwnershipValidationExtensions.cs @@ -0,0 +1,21 @@ +using GuruPR.Application.Common.Behaviors; +using GuruPR.Application.Common.Markers.Interfaces; +using GuruPR.Domain.Interfaces.Markers; + +using MediatR; + +using Microsoft.Extensions.DependencyInjection; + +namespace GuruPR.Application.Common.Extensions.Validation; + +public static class OwnershipValidationExtensions +{ + public static IServiceCollection AddOwnershipValidation(this IServiceCollection services) + where TRequest : IOwnedEntityRequest, IRequest + where TEntity : class, IOwnedEntity + { + services.AddTransient, OwnershipValidatorBehavior>(); + + return services; + } +} diff --git a/src/GuruPR.Application/Common/Extensions/Validation/ValidationExtensions.cs b/src/GuruPR.Application/Common/Extensions/Validation/ValidationExtensions.cs new file mode 100644 index 0000000..f9d70f4 --- /dev/null +++ b/src/GuruPR.Application/Common/Extensions/Validation/ValidationExtensions.cs @@ -0,0 +1,38 @@ +using FluentValidation; + +using GuruPR.Application.Common.Exceptions; + +namespace GuruPR.Application.Common.Extensions.Validation; + +public static class ValidationExtensions +{ + public static async Task ThrowIfInvalidAsync(this TValidator validator, + TInstance instance, + Func>, TException> exceptionFactory, + CancellationToken cancellationToken = default) + where TValidator : IValidator + where TException : ValidationExceptionBase + { + var result = await validator.ValidateAsync(instance, cancellationToken); + + if (!result.IsValid) + { + var errorDictionary = result.Errors.GroupBy(validationFailure => validationFailure.PropertyName) + .ToDictionary(group => group.Key, + group => group.Select(validationFailure => validationFailure.ErrorMessage) + .ToList()); + + throw exceptionFactory("Validation failed.", errorDictionary); + } + } + + public static async Task ThrowIfInvalidAsync(this TValidator validator, + TInstance instance, + CancellationToken cancellationToken = default) + where TValidator : IValidator + { + await validator.ThrowIfInvalidAsync(instance, + (message, errors) => new ValidationExceptionBase(message, errors), + cancellationToken); + } +} diff --git a/src/GuruPR.Application/Common/Extensions/Validation/ValidationRulesExtensions.cs b/src/GuruPR.Application/Common/Extensions/Validation/ValidationRulesExtensions.cs new file mode 100644 index 0000000..8aa7a75 --- /dev/null +++ b/src/GuruPR.Application/Common/Extensions/Validation/ValidationRulesExtensions.cs @@ -0,0 +1,20 @@ +using FluentValidation; + +namespace GuruPR.Application.Common.Extensions.Validation; + +public static class ValidationRulesExtensions +{ + public static IRuleBuilderOptions ValidId(this IRuleBuilder ruleBuilder, string domain) + { + return ruleBuilder.NotEmpty() + .WithMessage($"{domain} Id is required."); + } + + public static IRuleBuilderOptions ValidAbsoluteUrl(this IRuleBuilder ruleBuilder, string url) + { + return ruleBuilder.NotEmpty() + .WithMessage($"{url} is required.") + .Must(url => Uri.IsWellFormedUriString(url, UriKind.Absolute)) + .WithMessage($"{url} must be a valid absolute URL."); + } +} diff --git a/src/GuruPR.Application/Common/Factories/ExternalUserFactory.cs b/src/GuruPR.Application/Common/Factories/ExternalUserFactory.cs new file mode 100644 index 0000000..ef6da58 --- /dev/null +++ b/src/GuruPR.Application/Common/Factories/ExternalUserFactory.cs @@ -0,0 +1,21 @@ +using System.Security.Claims; + +using GuruPR.Application.Common.Interfaces.Application; +using GuruPR.Domain.Entities; + +namespace GuruPR.Application.Common.Factories; + +public class ExternalUserFactory : IExternalUserFactory +{ + public User Create(ClaimsPrincipal principal, string provider, string email) + { + return new User + { + Email = email, + UserName = email, + FirstName = principal.FindFirstValue(ClaimTypes.GivenName) ?? provider.Normalize(), + LastName = principal.FindFirstValue(ClaimTypes.Surname) ?? "User", + EmailConfirmed = true + }; + } +} diff --git a/src/GuruPR.Application/Interfaces/Application/IAccountLinkGenerator.cs b/src/GuruPR.Application/Common/Interfaces/Application/IAccountLinkGenerator.cs similarity index 63% rename from src/GuruPR.Application/Interfaces/Application/IAccountLinkGenerator.cs rename to src/GuruPR.Application/Common/Interfaces/Application/IAccountLinkGenerator.cs index 7629731..fdb554f 100644 --- a/src/GuruPR.Application/Interfaces/Application/IAccountLinkGenerator.cs +++ b/src/GuruPR.Application/Common/Interfaces/Application/IAccountLinkGenerator.cs @@ -1,4 +1,4 @@ -namespace GuruPR.Application.Interfaces.Application; +namespace GuruPR.Application.Common.Interfaces.Application; public interface IAccountLinkGenerator { diff --git a/src/GuruPR.Application/Interfaces/Application/IAccountService.cs b/src/GuruPR.Application/Common/Interfaces/Application/IAccountService.cs similarity index 92% rename from src/GuruPR.Application/Interfaces/Application/IAccountService.cs rename to src/GuruPR.Application/Common/Interfaces/Application/IAccountService.cs index 62c579e..3cfabff 100644 --- a/src/GuruPR.Application/Interfaces/Application/IAccountService.cs +++ b/src/GuruPR.Application/Common/Interfaces/Application/IAccountService.cs @@ -3,7 +3,7 @@ using GuruPR.Domain.Enums; using GuruPR.Domain.Requests; -namespace GuruPR.Application.Interfaces.Application; +namespace GuruPR.Application.Common.Interfaces.Application; public interface IAccountService { diff --git a/src/GuruPR.Application/Interfaces/Application/IEmailTemplateService.cs b/src/GuruPR.Application/Common/Interfaces/Application/IEmailTemplateService.cs similarity index 66% rename from src/GuruPR.Application/Interfaces/Application/IEmailTemplateService.cs rename to src/GuruPR.Application/Common/Interfaces/Application/IEmailTemplateService.cs index 0d0a50d..f348bd6 100644 --- a/src/GuruPR.Application/Interfaces/Application/IEmailTemplateService.cs +++ b/src/GuruPR.Application/Common/Interfaces/Application/IEmailTemplateService.cs @@ -1,4 +1,4 @@ -namespace GuruPR.Application.Interfaces.Application; +namespace GuruPR.Application.Common.Interfaces.Application; public interface IEmailTemplateService { diff --git a/src/GuruPR.Application/Common/Interfaces/Application/IExternalUserFactory.cs b/src/GuruPR.Application/Common/Interfaces/Application/IExternalUserFactory.cs new file mode 100644 index 0000000..372cb5a --- /dev/null +++ b/src/GuruPR.Application/Common/Interfaces/Application/IExternalUserFactory.cs @@ -0,0 +1,10 @@ +using System.Security.Claims; + +using GuruPR.Domain.Entities; + +namespace GuruPR.Application.Common.Interfaces.Application; + +public interface IExternalUserFactory +{ + User Create(ClaimsPrincipal claimsPrincipal, string provider, string email); +} diff --git a/src/GuruPR.Application/Common/Interfaces/Application/IExternalUserProvisioningService.cs b/src/GuruPR.Application/Common/Interfaces/Application/IExternalUserProvisioningService.cs new file mode 100644 index 0000000..55bce5d --- /dev/null +++ b/src/GuruPR.Application/Common/Interfaces/Application/IExternalUserProvisioningService.cs @@ -0,0 +1,8 @@ +using System.Security.Claims; + +namespace GuruPR.Application.Common.Interfaces.Application; + +public interface IExternalUserProvisioningService +{ + Task LoginWithExternalProviderAsync(ClaimsPrincipal? claimsPrincipal, string provider); +} diff --git a/src/GuruPR.Application/Interfaces/Application/IUrlValidator.cs b/src/GuruPR.Application/Common/Interfaces/Application/IUrlValidator.cs similarity index 56% rename from src/GuruPR.Application/Interfaces/Application/IUrlValidator.cs rename to src/GuruPR.Application/Common/Interfaces/Application/IUrlValidator.cs index c3ae4a9..35efa58 100644 --- a/src/GuruPR.Application/Interfaces/Application/IUrlValidator.cs +++ b/src/GuruPR.Application/Common/Interfaces/Application/IUrlValidator.cs @@ -1,4 +1,4 @@ -namespace GuruPR.Application.Interfaces.Application; +namespace GuruPR.Application.Common.Interfaces.Application; public interface IUrlValidator { diff --git a/src/GuruPR.Application/Common/Interfaces/Application/IUserRoleService.cs b/src/GuruPR.Application/Common/Interfaces/Application/IUserRoleService.cs new file mode 100644 index 0000000..be33ae2 --- /dev/null +++ b/src/GuruPR.Application/Common/Interfaces/Application/IUserRoleService.cs @@ -0,0 +1,10 @@ +using GuruPR.Domain.Enums; + +namespace GuruPR.Application.Common.Interfaces.Application; + +public interface IUserRoleService +{ + Task AssignRoleAsync(string userId, UserRole userRole); + + Task RemoveRoleAsync(string userId, UserRole userRole); +} diff --git a/src/GuruPR.Application/Common/Interfaces/Infrastructure/IAIChatProvider.cs b/src/GuruPR.Application/Common/Interfaces/Infrastructure/IAIChatProvider.cs new file mode 100644 index 0000000..7d0753c --- /dev/null +++ b/src/GuruPR.Application/Common/Interfaces/Infrastructure/IAIChatProvider.cs @@ -0,0 +1,13 @@ +using GuruPR.Application.Features.Conversations.Models.CreateCompletion; +using GuruPR.Domain.Entities.Agents; +using GuruPR.Domain.Entities.Conversation; +using GuruPR.Domain.Entities.Message; + +namespace GuruPR.Application.Common.Interfaces.Infrastructure; + +public interface IAIChatProvider +{ + Task ExecuteAsync(Agent agent, Conversation conversation, IList messages, string userMessage, CancellationToken cancellationToken = default); + + Task GenerateSummaryAsync(IList messages, string? existingSummary, CancellationToken cancellationToken = default); +} diff --git a/src/GuruPR.Application/Common/Interfaces/Infrastructure/IExternalAuthService.cs b/src/GuruPR.Application/Common/Interfaces/Infrastructure/IExternalAuthService.cs new file mode 100644 index 0000000..1cb7c7b --- /dev/null +++ b/src/GuruPR.Application/Common/Interfaces/Infrastructure/IExternalAuthService.cs @@ -0,0 +1,10 @@ +using Microsoft.AspNetCore.Mvc; + +namespace GuruPR.Application.Common.Interfaces.Infrastructure; + +public interface IExternalAuthService +{ + Task InitiateGoogleLoginAsync(string? returnUrl); + + Task HandleGoogleCallbackAsync(string returnUrl); +} diff --git a/src/GuruPR.Application/Interfaces/Infrastructure/IHasher.cs b/src/GuruPR.Application/Common/Interfaces/Infrastructure/IHasher.cs similarity index 61% rename from src/GuruPR.Application/Interfaces/Infrastructure/IHasher.cs rename to src/GuruPR.Application/Common/Interfaces/Infrastructure/IHasher.cs index 9805fbc..30e2acf 100644 --- a/src/GuruPR.Application/Interfaces/Infrastructure/IHasher.cs +++ b/src/GuruPR.Application/Common/Interfaces/Infrastructure/IHasher.cs @@ -1,4 +1,4 @@ -namespace GuruPR.Application.Interfaces.Infrastructure; +namespace GuruPR.Application.Common.Interfaces.Infrastructure; public interface IHasher { string Hash(string input); diff --git a/src/GuruPR.Application/Interfaces/Infrastructure/ISpotifyService.cs b/src/GuruPR.Application/Common/Interfaces/Infrastructure/ISpotifyService.cs similarity index 73% rename from src/GuruPR.Application/Interfaces/Infrastructure/ISpotifyService.cs rename to src/GuruPR.Application/Common/Interfaces/Infrastructure/ISpotifyService.cs index 704a841..d0634b9 100644 --- a/src/GuruPR.Application/Interfaces/Infrastructure/ISpotifyService.cs +++ b/src/GuruPR.Application/Common/Interfaces/Infrastructure/ISpotifyService.cs @@ -1,4 +1,4 @@ -namespace GuruPR.Application.Interfaces.Infrastructure; +namespace GuruPR.Application.Common.Interfaces.Infrastructure; public interface ISpotifyService { diff --git a/src/GuruPR.Application/Interfaces/Infrastructure/ITokenEncryptionService.cs b/src/GuruPR.Application/Common/Interfaces/Infrastructure/ITokenEncryptionService.cs similarity index 89% rename from src/GuruPR.Application/Interfaces/Infrastructure/ITokenEncryptionService.cs rename to src/GuruPR.Application/Common/Interfaces/Infrastructure/ITokenEncryptionService.cs index 0997598..19591d0 100644 --- a/src/GuruPR.Application/Interfaces/Infrastructure/ITokenEncryptionService.cs +++ b/src/GuruPR.Application/Common/Interfaces/Infrastructure/ITokenEncryptionService.cs @@ -1,4 +1,4 @@ -namespace GuruPR.Application.Interfaces.Infrastructure; +namespace GuruPR.Application.Common.Interfaces.Infrastructure; public interface ITokenEncryptionService { diff --git a/src/GuruPR.Application/Common/Interfaces/Infrastructure/ITokenService.cs b/src/GuruPR.Application/Common/Interfaces/Infrastructure/ITokenService.cs new file mode 100644 index 0000000..36c61c6 --- /dev/null +++ b/src/GuruPR.Application/Common/Interfaces/Infrastructure/ITokenService.cs @@ -0,0 +1,12 @@ +using GuruPR.Domain.Entities; + +namespace GuruPR.Application.Common.Interfaces.Infrastructure; + +public interface ITokenService +{ + Task IssueNewTokenPairAsync(User user); + + Task RenewAccessTokenAsync(User user); + + Task RevokeTokensAsync(User user); +} diff --git a/src/GuruPR.Application/Common/Interfaces/Infrastructure/SemanticKernel/Plugins/IAgentTool.cs b/src/GuruPR.Application/Common/Interfaces/Infrastructure/SemanticKernel/Plugins/IAgentTool.cs new file mode 100644 index 0000000..15c923d --- /dev/null +++ b/src/GuruPR.Application/Common/Interfaces/Infrastructure/SemanticKernel/Plugins/IAgentTool.cs @@ -0,0 +1,6 @@ +namespace GuruPR.Application.Common.Interfaces.Infrastructure.SemanticKernel.Plugins; + +public interface IAgentTool +{ + string Name { get; } +} diff --git a/src/GuruPR.Application/Interfaces/Persistence/IAgentRepository.cs b/src/GuruPR.Application/Common/Interfaces/Persistence/IAgentRepository.cs similarity index 55% rename from src/GuruPR.Application/Interfaces/Persistence/IAgentRepository.cs rename to src/GuruPR.Application/Common/Interfaces/Persistence/IAgentRepository.cs index 7aded42..9d23f95 100644 --- a/src/GuruPR.Application/Interfaces/Persistence/IAgentRepository.cs +++ b/src/GuruPR.Application/Common/Interfaces/Persistence/IAgentRepository.cs @@ -1,6 +1,6 @@ -using GuruPR.Domain.Entities; +using GuruPR.Domain.Entities.Agents; -namespace GuruPR.Application.Interfaces.Persistence; +namespace GuruPR.Application.Common.Interfaces.Persistence; public interface IAgentRepository : IGenericRepository { diff --git a/src/GuruPR.Application/Interfaces/Persistence/IConversationRepository.cs b/src/GuruPR.Application/Common/Interfaces/Persistence/IConversationRepository.cs similarity index 76% rename from src/GuruPR.Application/Interfaces/Persistence/IConversationRepository.cs rename to src/GuruPR.Application/Common/Interfaces/Persistence/IConversationRepository.cs index 29a6efa..773271b 100644 --- a/src/GuruPR.Application/Interfaces/Persistence/IConversationRepository.cs +++ b/src/GuruPR.Application/Common/Interfaces/Persistence/IConversationRepository.cs @@ -1,6 +1,6 @@ using GuruPR.Domain.Entities.Conversation; -namespace GuruPR.Application.Interfaces.Persistence; +namespace GuruPR.Application.Common.Interfaces.Persistence; public interface IConversationRepository : IGenericRepository { diff --git a/src/GuruPR.Application/Common/Interfaces/Persistence/IGenericRepository.cs b/src/GuruPR.Application/Common/Interfaces/Persistence/IGenericRepository.cs new file mode 100644 index 0000000..bfb434b --- /dev/null +++ b/src/GuruPR.Application/Common/Interfaces/Persistence/IGenericRepository.cs @@ -0,0 +1,18 @@ +using System.Linq.Expressions; + +namespace GuruPR.Application.Common.Interfaces.Persistence; + +public interface IGenericRepository where T : class +{ + IQueryable AsQueryable(); + + Task GetByIdAsync(string id, CancellationToken cancellationToken = default); + + Task> GetAllAsync(Expression>? predicate = null, CancellationToken cancellationToken = default); + + Task AddAsync(T entity, CancellationToken cancellationToken = default); + + void Update(T entity); + + void Delete(T entity); +} diff --git a/src/GuruPR.Application/Common/Interfaces/Persistence/IMessageRepository.cs b/src/GuruPR.Application/Common/Interfaces/Persistence/IMessageRepository.cs new file mode 100644 index 0000000..0614920 --- /dev/null +++ b/src/GuruPR.Application/Common/Interfaces/Persistence/IMessageRepository.cs @@ -0,0 +1,10 @@ +using GuruPR.Domain.Entities.Message; + +namespace GuruPR.Application.Common.Interfaces.Persistence; + +public interface IMessageRepository : IGenericRepository +{ + Task> GetMessagesAsync(string conversationId, int? lastMessages = null, CancellationToken cancellationToken = default); + + Task DeleteConversationMessagesAsync(string conversationId, CancellationToken cancellationToken = default); +} diff --git a/src/GuruPR.Application/Common/Interfaces/Persistence/IOwnedGenericRepository.cs b/src/GuruPR.Application/Common/Interfaces/Persistence/IOwnedGenericRepository.cs new file mode 100644 index 0000000..d2245e9 --- /dev/null +++ b/src/GuruPR.Application/Common/Interfaces/Persistence/IOwnedGenericRepository.cs @@ -0,0 +1,8 @@ +using GuruPR.Domain.Interfaces.Markers; + +namespace GuruPR.Application.Common.Interfaces.Persistence; + +public interface IOwnedGenericRepository : IGenericRepository where T : class, IOwnedEntity +{ + Task GetByIdAndOwnerAsync(string id, string ownerId, CancellationToken cancellationToken = default); +} diff --git a/src/GuruPR.Application/Common/Interfaces/Persistence/IProviderConnectionRepository.cs b/src/GuruPR.Application/Common/Interfaces/Persistence/IProviderConnectionRepository.cs new file mode 100644 index 0000000..1327318 --- /dev/null +++ b/src/GuruPR.Application/Common/Interfaces/Persistence/IProviderConnectionRepository.cs @@ -0,0 +1,8 @@ +using GuruPR.Domain.Entities.ProviderConnection; + +namespace GuruPR.Application.Common.Interfaces.Persistence; + +public interface IProviderConnectionRepository : IGenericRepository +{ + Task DeleteProviderConnectionsByProviderIdAsync(string providerId, CancellationToken cancellationToken = default); +} diff --git a/src/GuruPR.Application/Interfaces/Persistence/IProviderRepository.cs b/src/GuruPR.Application/Common/Interfaces/Persistence/IProviderRepository.cs similarity index 65% rename from src/GuruPR.Application/Interfaces/Persistence/IProviderRepository.cs rename to src/GuruPR.Application/Common/Interfaces/Persistence/IProviderRepository.cs index ec49b69..ccbaaf4 100644 --- a/src/GuruPR.Application/Interfaces/Persistence/IProviderRepository.cs +++ b/src/GuruPR.Application/Common/Interfaces/Persistence/IProviderRepository.cs @@ -1,7 +1,7 @@ -using GuruPR.Domain.Entities.Enums; -using GuruPR.Domain.Entities.OAuth; +using GuruPR.Domain.Entities.Provider; +using GuruPR.Domain.Entities.Provider.Enums; -namespace GuruPR.Application.Interfaces.Persistence; +namespace GuruPR.Application.Common.Interfaces.Persistence; public interface IProviderRepository : IGenericRepository { diff --git a/src/GuruPR.Application/Interfaces/Persistence/IToolRepository.cs b/src/GuruPR.Application/Common/Interfaces/Persistence/IToolRepository.cs similarity index 63% rename from src/GuruPR.Application/Interfaces/Persistence/IToolRepository.cs rename to src/GuruPR.Application/Common/Interfaces/Persistence/IToolRepository.cs index dd3bc1d..6334877 100644 --- a/src/GuruPR.Application/Interfaces/Persistence/IToolRepository.cs +++ b/src/GuruPR.Application/Common/Interfaces/Persistence/IToolRepository.cs @@ -1,6 +1,6 @@ using GuruPR.Domain.Entities.Tool; -namespace GuruPR.Application.Interfaces.Persistence; +namespace GuruPR.Application.Common.Interfaces.Persistence; public interface IToolRepository : IGenericRepository { diff --git a/src/GuruPR.Application/Interfaces/Persistence/IUnitOfWork.cs b/src/GuruPR.Application/Common/Interfaces/Persistence/IUnitOfWork.cs similarity index 91% rename from src/GuruPR.Application/Interfaces/Persistence/IUnitOfWork.cs rename to src/GuruPR.Application/Common/Interfaces/Persistence/IUnitOfWork.cs index 8946227..d47d36f 100644 --- a/src/GuruPR.Application/Interfaces/Persistence/IUnitOfWork.cs +++ b/src/GuruPR.Application/Common/Interfaces/Persistence/IUnitOfWork.cs @@ -1,4 +1,4 @@ -namespace GuruPR.Application.Interfaces.Persistence; +namespace GuruPR.Application.Common.Interfaces.Persistence; public interface IUnitOfWork : IDisposable { @@ -7,6 +7,7 @@ public interface IUnitOfWork : IDisposable IMessageRepository Messages { get; } IProviderRepository Providers { get; } IConversationRepository Conversations { get; } + IProviderConnectionRepository ProviderConnections { get; } IUserRepository Users { get; } diff --git a/src/GuruPR.Application/Interfaces/Persistence/IUserRepository.cs b/src/GuruPR.Application/Common/Interfaces/Persistence/IUserRepository.cs similarity index 70% rename from src/GuruPR.Application/Interfaces/Persistence/IUserRepository.cs rename to src/GuruPR.Application/Common/Interfaces/Persistence/IUserRepository.cs index 0736a33..a4ecef6 100644 --- a/src/GuruPR.Application/Interfaces/Persistence/IUserRepository.cs +++ b/src/GuruPR.Application/Common/Interfaces/Persistence/IUserRepository.cs @@ -1,6 +1,6 @@ using GuruPR.Domain.Entities; -namespace GuruPR.Application.Interfaces.Persistence; +namespace GuruPR.Application.Common.Interfaces.Persistence; public interface IUserRepository { diff --git a/src/GuruPR.Application/Common/Interfaces/Presentation/ICurrentUserService.cs b/src/GuruPR.Application/Common/Interfaces/Presentation/ICurrentUserService.cs new file mode 100644 index 0000000..f1c51f8 --- /dev/null +++ b/src/GuruPR.Application/Common/Interfaces/Presentation/ICurrentUserService.cs @@ -0,0 +1,12 @@ +namespace GuruPR.Application.Common.Interfaces.Presentation; + +/// +/// Provides access to properties of the current authenticated user. +/// +public interface ICurrentUserService +{ + /// + /// Unique user identifier (usually JWT sub claim). Null if unauthenticated. + /// + string? UserId { get; } +} diff --git a/src/GuruPR.Application/Common/Markers/ApplicationMarker.cs b/src/GuruPR.Application/Common/Markers/ApplicationMarker.cs new file mode 100644 index 0000000..65f4745 --- /dev/null +++ b/src/GuruPR.Application/Common/Markers/ApplicationMarker.cs @@ -0,0 +1,3 @@ +namespace GuruPR.Application.Common.Markers; + +public sealed class ApplicationMarker; diff --git a/src/GuruPR.Application/Common/Markers/Interfaces/IOwnedEntityRequest.cs b/src/GuruPR.Application/Common/Markers/Interfaces/IOwnedEntityRequest.cs new file mode 100644 index 0000000..3a191fc --- /dev/null +++ b/src/GuruPR.Application/Common/Markers/Interfaces/IOwnedEntityRequest.cs @@ -0,0 +1,8 @@ +using GuruPR.Domain.Interfaces.Markers; + +namespace GuruPR.Application.Common.Markers.Interfaces; + +public interface IOwnedEntityRequest : IUserContextCommand where TEntity : IOwnedEntity +{ + string Id { get; } +} diff --git a/src/GuruPR.Application/Common/Markers/Interfaces/IUserContextCommand.cs b/src/GuruPR.Application/Common/Markers/Interfaces/IUserContextCommand.cs new file mode 100644 index 0000000..5d9a3d3 --- /dev/null +++ b/src/GuruPR.Application/Common/Markers/Interfaces/IUserContextCommand.cs @@ -0,0 +1,6 @@ +namespace GuruPR.Application.Common.Markers.Interfaces; + +public interface IUserContextCommand +{ + string UserId { get; set; } +} diff --git a/src/GuruPR.Application/Common/Models/PaginatedList.cs b/src/GuruPR.Application/Common/Models/PaginatedList.cs new file mode 100644 index 0000000..299be5c --- /dev/null +++ b/src/GuruPR.Application/Common/Models/PaginatedList.cs @@ -0,0 +1,35 @@ +using Microsoft.EntityFrameworkCore; + +namespace GuruPR.Application.Common.Models; + +public class PaginatedList +{ + public List Items { get; } + public int PageIndex { get; } + public int TotalPages { get; } + public int TotalCount { get; } + + public bool HasPreviousPage => PageIndex > 1; + public bool HasNextPage => PageIndex < TotalPages; + + private PaginatedList(List items, int count, int pageIndex, int pageSize) + { + TotalCount = count; + PageIndex = pageIndex; + TotalPages = (int)Math.Ceiling(count / (double)pageSize); + Items = items; + } + + public static async Task> CreateAsync(IQueryable source, + int pageIndex, + int pageSize, + CancellationToken cancellationToken = default) + { + var count = await source.CountAsync(cancellationToken); + var items = await source.Skip((pageIndex - 1) * pageSize) + .Take(pageSize) + .ToListAsync(cancellationToken); + + return new PaginatedList(items, count, pageIndex, pageSize); + } +} diff --git a/src/GuruPR.Application/Services/Email/EmailTemplateService.cs b/src/GuruPR.Application/Common/Services/EmailTemplateService.cs similarity index 96% rename from src/GuruPR.Application/Services/Email/EmailTemplateService.cs rename to src/GuruPR.Application/Common/Services/EmailTemplateService.cs index a398271..1f66e34 100644 --- a/src/GuruPR.Application/Services/Email/EmailTemplateService.cs +++ b/src/GuruPR.Application/Common/Services/EmailTemplateService.cs @@ -1,11 +1,11 @@ using System.Text.Encodings.Web; -using GuruPR.Application.Interfaces.Application; +using GuruPR.Application.Common.Interfaces.Application; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; -namespace GuruPR.Application.Services.Email; +namespace GuruPR.Application.Common.Services; public class EmailTemplateService : IEmailTemplateService { private readonly LinkGenerator _linkGenerator; diff --git a/src/GuruPR.Application/Common/Services/ExternalUserProvisioningService.cs b/src/GuruPR.Application/Common/Services/ExternalUserProvisioningService.cs new file mode 100644 index 0000000..5411193 --- /dev/null +++ b/src/GuruPR.Application/Common/Services/ExternalUserProvisioningService.cs @@ -0,0 +1,117 @@ +using System.Security.Claims; + +using GuruPR.Application.Common.Interfaces.Application; +using GuruPR.Application.Common.Interfaces.Infrastructure; +using GuruPR.Application.Common.Interfaces.Persistence; +using GuruPR.Application.Exceptions.Account; +using GuruPR.Domain.Entities; +using GuruPR.Domain.Enums; + +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; + +namespace GuruPR.Application.Common.Services; + +public class ExternalUserProvisioningService : IExternalUserProvisioningService +{ + private readonly ILogger _logger; + private readonly IUnitOfWork _unitOfWork; + private readonly ITokenService _tokenService; + private readonly IUserRoleService _userRoleService; + private readonly IExternalUserFactory _externalUserFactory; + private readonly UserManager _userManager; + + public ExternalUserProvisioningService(ILogger logger, + IUnitOfWork unitOfWork, + ITokenService tokenService, + IUserRoleService userRoleService, + IExternalUserFactory externalUserFactory, + UserManager userManager) + { + _logger = logger; + _unitOfWork = unitOfWork; + _tokenService = tokenService; + _userRoleService = userRoleService; + _externalUserFactory = externalUserFactory; + _userManager = userManager; + } + + public async Task LoginWithExternalProviderAsync(ClaimsPrincipal? claimsPrincipal, string provider) + { + if (claimsPrincipal == null) + { + throw new ExternalLoginProviderException(provider, "Claims principal is missing"); + } + + var email = ExtractEmailFromClaims(claimsPrincipal, provider); + var user = await _userManager.FindByEmailAsync(email); + + await _unitOfWork.BeginUserManagementTransactionAsync(); + + try + { + if (user == null) + { + user = await CreateUserFromExternalProviderClaimsAsync(claimsPrincipal, provider, email); + } + + await _unitOfWork.CommitUserManagementTransactionAsync(); + await _tokenService.IssueNewTokenPairAsync(user); + } + catch (Exception) + { + await _unitOfWork.RollbackUserManagementTransactionAsync(); + + throw; + } + } + + private static string ExtractEmailFromClaims(ClaimsPrincipal claimsPrincipal, string provider) + { + var email = claimsPrincipal.FindFirstValue(ClaimTypes.Email); + if (string.IsNullOrWhiteSpace(email)) + { + throw new ExternalLoginProviderException(provider, "Email claim is missing"); + } + + return email; + } + + private async Task CreateUserFromExternalProviderClaimsAsync(ClaimsPrincipal claimsPrincipal, string provider, string email) + { + var user = _externalUserFactory.Create(claimsPrincipal, provider, email); + + var createResult = await _userManager.CreateAsync(user); + if (!createResult.Succeeded) + { + var errors = string.Join(", ", createResult.Errors.Select(e => e.Description)); + + _logger.LogError("Error creating user from external provider {Provider}: {Errors}", provider, errors); + + throw new RegistrationFailedException($"Failed to create a user account using the external provider {provider}."); + } + + await _userRoleService.AssignRoleAsync(user.Id.ToString(), UserRole.User); + await AddLoginInfoAsync(user, provider, claimsPrincipal); + + return user; + } + + private async Task AddLoginInfoAsync(User user, string provider, ClaimsPrincipal claimsPrincipal) + { + var userNameIdentifier = claimsPrincipal.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrWhiteSpace(userNameIdentifier)) + { + throw new ExternalLoginProviderException(provider, $"{provider} user ID claim is missing"); + } + + var loginInfo = new UserLoginInfo(provider, userNameIdentifier, provider); + var loginResult = await _userManager.AddLoginAsync(user, loginInfo); + if (!loginResult.Succeeded) + { + var errors = string.Join(", ", loginResult.Errors.Select(e => e.Description)); + + throw new ExternalLoginProviderException(provider, $"Unable to link {provider} login: {errors}"); + } + } +} diff --git a/src/GuruPR.Application/Services/Validators/UrlValidator.cs b/src/GuruPR.Application/Common/Services/UrlValidator.cs similarity index 86% rename from src/GuruPR.Application/Services/Validators/UrlValidator.cs rename to src/GuruPR.Application/Common/Services/UrlValidator.cs index 11f39bb..2603369 100644 --- a/src/GuruPR.Application/Services/Validators/UrlValidator.cs +++ b/src/GuruPR.Application/Common/Services/UrlValidator.cs @@ -1,10 +1,10 @@ -using GuruPR.Application.Exceptions.Account; -using GuruPR.Application.Interfaces.Application; -using GuruPR.Application.Settings.Security; +using GuruPR.Application.Common.Interfaces.Application; +using GuruPR.Application.Common.Settings.Security; +using GuruPR.Application.Exceptions.Account; using Microsoft.Extensions.Options; -namespace GuruPR.Application.Services.Validators; +namespace GuruPR.Application.Common.Services; public class UrlValidator : IUrlValidator { diff --git a/src/GuruPR.Application/Settings/Authentication/ExternalAuthentication.cs b/src/GuruPR.Application/Common/Settings/Authentication/ExternalAuthentication.cs similarity index 55% rename from src/GuruPR.Application/Settings/Authentication/ExternalAuthentication.cs rename to src/GuruPR.Application/Common/Settings/Authentication/ExternalAuthentication.cs index 271ad12..bd792a7 100644 --- a/src/GuruPR.Application/Settings/Authentication/ExternalAuthentication.cs +++ b/src/GuruPR.Application/Common/Settings/Authentication/ExternalAuthentication.cs @@ -1,6 +1,6 @@ -using GuruPR.Application.Settings.Authentication.Providers; +using GuruPR.Application.Common.Settings.Authentication.Providers; -namespace GuruPR.Application.Settings.Authentication; +namespace GuruPR.Application.Common.Settings.Authentication; public class ExternalAuthentication { diff --git a/src/GuruPR.Application/Settings/Security/JwtSettings.cs b/src/GuruPR.Application/Common/Settings/Authentication/JwtSettings.cs similarity index 82% rename from src/GuruPR.Application/Settings/Security/JwtSettings.cs rename to src/GuruPR.Application/Common/Settings/Authentication/JwtSettings.cs index cb47e5f..917dc0c 100644 --- a/src/GuruPR.Application/Settings/Security/JwtSettings.cs +++ b/src/GuruPR.Application/Common/Settings/Authentication/JwtSettings.cs @@ -1,4 +1,4 @@ -namespace GuruPR.Application.Settings.Security; +namespace GuruPR.Application.Common.Settings.Authentication; public class JwtSettings : ISettings { diff --git a/src/GuruPR.Application/Settings/Authentication/Providers/GoogleProvider.cs b/src/GuruPR.Application/Common/Settings/Authentication/Providers/GoogleProvider.cs similarity index 65% rename from src/GuruPR.Application/Settings/Authentication/Providers/GoogleProvider.cs rename to src/GuruPR.Application/Common/Settings/Authentication/Providers/GoogleProvider.cs index fc52188..d1a621e 100644 --- a/src/GuruPR.Application/Settings/Authentication/Providers/GoogleProvider.cs +++ b/src/GuruPR.Application/Common/Settings/Authentication/Providers/GoogleProvider.cs @@ -1,4 +1,4 @@ -namespace GuruPR.Application.Settings.Authentication.Providers; +namespace GuruPR.Application.Common.Settings.Authentication.Providers; public class GoogleProvider { diff --git a/src/GuruPR.Application/Common/Settings/Authentication/RefreshTokenSettings.cs b/src/GuruPR.Application/Common/Settings/Authentication/RefreshTokenSettings.cs new file mode 100644 index 0000000..4ce96f9 --- /dev/null +++ b/src/GuruPR.Application/Common/Settings/Authentication/RefreshTokenSettings.cs @@ -0,0 +1,8 @@ +namespace GuruPR.Application.Common.Settings.Authentication; + +public class RefreshTokenSettings : ISettings +{ + public static string SectionName => "RefreshToken"; + + public required int ExpirationTimeInDays { get; set; } +} diff --git a/src/GuruPR.Application/Settings/Database/CosmosSettings.cs b/src/GuruPR.Application/Common/Settings/Database/CosmosSettings.cs similarity index 81% rename from src/GuruPR.Application/Settings/Database/CosmosSettings.cs rename to src/GuruPR.Application/Common/Settings/Database/CosmosSettings.cs index fc68a95..3689962 100644 --- a/src/GuruPR.Application/Settings/Database/CosmosSettings.cs +++ b/src/GuruPR.Application/Common/Settings/Database/CosmosSettings.cs @@ -1,4 +1,4 @@ -namespace GuruPR.Application.Settings.Database; +namespace GuruPR.Application.Common.Settings.Database; public class CosmosSettings { diff --git a/src/GuruPR.Application/Settings/Database/DefaultAdminSettings.cs b/src/GuruPR.Application/Common/Settings/Database/DefaultAdminSettings.cs similarity index 83% rename from src/GuruPR.Application/Settings/Database/DefaultAdminSettings.cs rename to src/GuruPR.Application/Common/Settings/Database/DefaultAdminSettings.cs index 0ae0a8f..08aa193 100644 --- a/src/GuruPR.Application/Settings/Database/DefaultAdminSettings.cs +++ b/src/GuruPR.Application/Common/Settings/Database/DefaultAdminSettings.cs @@ -1,4 +1,4 @@ -namespace GuruPR.Application.Settings.Database; +namespace GuruPR.Application.Common.Settings.Database; public class DefaultAdminSettings { diff --git a/src/GuruPR.Application/Settings/Database/PostgresSettings.cs b/src/GuruPR.Application/Common/Settings/Database/PostgresSettings.cs similarity index 71% rename from src/GuruPR.Application/Settings/Database/PostgresSettings.cs rename to src/GuruPR.Application/Common/Settings/Database/PostgresSettings.cs index 4b16202..bdcdb58 100644 --- a/src/GuruPR.Application/Settings/Database/PostgresSettings.cs +++ b/src/GuruPR.Application/Common/Settings/Database/PostgresSettings.cs @@ -1,4 +1,4 @@ -namespace GuruPR.Application.Settings.Database; +namespace GuruPR.Application.Common.Settings.Database; public class PostgresSettings { diff --git a/src/GuruPR.Application/Settings/Email/GmailingAppSettings.cs b/src/GuruPR.Application/Common/Settings/Email/GmailingAppSettings.cs similarity index 84% rename from src/GuruPR.Application/Settings/Email/GmailingAppSettings.cs rename to src/GuruPR.Application/Common/Settings/Email/GmailingAppSettings.cs index 8d7a1af..402a5b1 100644 --- a/src/GuruPR.Application/Settings/Email/GmailingAppSettings.cs +++ b/src/GuruPR.Application/Common/Settings/Email/GmailingAppSettings.cs @@ -1,4 +1,4 @@ -namespace GuruPR.Application.Settings.Email; +namespace GuruPR.Application.Common.Settings.Email; public class GmailingAppSettings : ISettings { diff --git a/src/GuruPR.Application/Settings/FrontEnd/FrontEndSettings.cs b/src/GuruPR.Application/Common/Settings/FrontEnd/FrontEndSettings.cs similarity index 84% rename from src/GuruPR.Application/Settings/FrontEnd/FrontEndSettings.cs rename to src/GuruPR.Application/Common/Settings/FrontEnd/FrontEndSettings.cs index 5998dc3..42014e1 100644 --- a/src/GuruPR.Application/Settings/FrontEnd/FrontEndSettings.cs +++ b/src/GuruPR.Application/Common/Settings/FrontEnd/FrontEndSettings.cs @@ -1,4 +1,4 @@ -namespace GuruPR.Application.Settings.FrontEnd; +namespace GuruPR.Application.Common.Settings.FrontEnd; public class FrontEndSettings : ISettings { diff --git a/src/GuruPR.Application/Settings/ISettings.cs b/src/GuruPR.Application/Common/Settings/ISettings.cs similarity index 62% rename from src/GuruPR.Application/Settings/ISettings.cs rename to src/GuruPR.Application/Common/Settings/ISettings.cs index c0a9abe..95b11bb 100644 --- a/src/GuruPR.Application/Settings/ISettings.cs +++ b/src/GuruPR.Application/Common/Settings/ISettings.cs @@ -1,4 +1,4 @@ -namespace GuruPR.Application.Settings; +namespace GuruPR.Application.Common.Settings; public interface ISettings { diff --git a/src/GuruPR.Application/Settings/ModelConfiguration/AzureOpenAI/AzureOpenAIModel.cs b/src/GuruPR.Application/Common/Settings/ModelConfiguration/AzureOpenAI/AzureOpenAIModel.cs similarity index 72% rename from src/GuruPR.Application/Settings/ModelConfiguration/AzureOpenAI/AzureOpenAIModel.cs rename to src/GuruPR.Application/Common/Settings/ModelConfiguration/AzureOpenAI/AzureOpenAIModel.cs index 32b32de..922e5a6 100644 --- a/src/GuruPR.Application/Settings/ModelConfiguration/AzureOpenAI/AzureOpenAIModel.cs +++ b/src/GuruPR.Application/Common/Settings/ModelConfiguration/AzureOpenAI/AzureOpenAIModel.cs @@ -1,6 +1,6 @@ using JetBrains.Annotations; -namespace GuruPR.Application.Settings.ModelConfiguration.AzureOpenAI; +namespace GuruPR.Application.Common.Settings.ModelConfiguration.AzureOpenAI; public class AzureOpenAIModel { diff --git a/src/GuruPR.Application/Settings/ModelConfiguration/AzureOpenAI/AzureOpenAIModels.cs b/src/GuruPR.Application/Common/Settings/ModelConfiguration/AzureOpenAI/AzureOpenAIModels.cs similarity index 74% rename from src/GuruPR.Application/Settings/ModelConfiguration/AzureOpenAI/AzureOpenAIModels.cs rename to src/GuruPR.Application/Common/Settings/ModelConfiguration/AzureOpenAI/AzureOpenAIModels.cs index ad6fc33..4f76e9c 100644 --- a/src/GuruPR.Application/Settings/ModelConfiguration/AzureOpenAI/AzureOpenAIModels.cs +++ b/src/GuruPR.Application/Common/Settings/ModelConfiguration/AzureOpenAI/AzureOpenAIModels.cs @@ -1,4 +1,4 @@ -namespace GuruPR.Application.Settings.ModelConfiguration.AzureOpenAI; +namespace GuruPR.Application.Common.Settings.ModelConfiguration.AzureOpenAI; public class AzureOpenAIModels { diff --git a/src/GuruPR.Application/Settings/ModelConfiguration/HuggingFace/HuggingFaceModel.cs b/src/GuruPR.Application/Common/Settings/ModelConfiguration/HuggingFace/HuggingFaceModel.cs similarity index 72% rename from src/GuruPR.Application/Settings/ModelConfiguration/HuggingFace/HuggingFaceModel.cs rename to src/GuruPR.Application/Common/Settings/ModelConfiguration/HuggingFace/HuggingFaceModel.cs index b4287b7..f9215ac 100644 --- a/src/GuruPR.Application/Settings/ModelConfiguration/HuggingFace/HuggingFaceModel.cs +++ b/src/GuruPR.Application/Common/Settings/ModelConfiguration/HuggingFace/HuggingFaceModel.cs @@ -1,6 +1,6 @@ using JetBrains.Annotations; -namespace GuruPR.Application.Settings.ModelConfiguration.HuggingFace; +namespace GuruPR.Application.Common.Settings.ModelConfiguration.HuggingFace; public class HuggingFaceModel { diff --git a/src/GuruPR.Application/Settings/ModelConfiguration/HuggingFace/HuggingFaceModels.cs b/src/GuruPR.Application/Common/Settings/ModelConfiguration/HuggingFace/HuggingFaceModels.cs similarity index 74% rename from src/GuruPR.Application/Settings/ModelConfiguration/HuggingFace/HuggingFaceModels.cs rename to src/GuruPR.Application/Common/Settings/ModelConfiguration/HuggingFace/HuggingFaceModels.cs index d348ac9..1b7adba 100644 --- a/src/GuruPR.Application/Settings/ModelConfiguration/HuggingFace/HuggingFaceModels.cs +++ b/src/GuruPR.Application/Common/Settings/ModelConfiguration/HuggingFace/HuggingFaceModels.cs @@ -1,4 +1,4 @@ -namespace GuruPR.Application.Settings.ModelConfiguration.HuggingFace; +namespace GuruPR.Application.Common.Settings.ModelConfiguration.HuggingFace; public class HuggingFaceModels { diff --git a/src/GuruPR.Application/Settings/Security/AllowedOriginsSettings.cs b/src/GuruPR.Application/Common/Settings/Security/AllowedOriginsSettings.cs similarity index 75% rename from src/GuruPR.Application/Settings/Security/AllowedOriginsSettings.cs rename to src/GuruPR.Application/Common/Settings/Security/AllowedOriginsSettings.cs index 0265e5f..aaebb15 100644 --- a/src/GuruPR.Application/Settings/Security/AllowedOriginsSettings.cs +++ b/src/GuruPR.Application/Common/Settings/Security/AllowedOriginsSettings.cs @@ -1,4 +1,4 @@ -namespace GuruPR.Application.Settings.Security; +namespace GuruPR.Application.Common.Settings.Security; public class AllowedOriginsSettings : ISettings { diff --git a/src/GuruPR.Application/Settings/Security/EmailValidationSettings.cs b/src/GuruPR.Application/Common/Settings/Security/EmailValidationSettings.cs similarity index 77% rename from src/GuruPR.Application/Settings/Security/EmailValidationSettings.cs rename to src/GuruPR.Application/Common/Settings/Security/EmailValidationSettings.cs index 634c980..7bdc651 100644 --- a/src/GuruPR.Application/Settings/Security/EmailValidationSettings.cs +++ b/src/GuruPR.Application/Common/Settings/Security/EmailValidationSettings.cs @@ -1,4 +1,4 @@ -namespace GuruPR.Application.Settings.Security; +namespace GuruPR.Application.Common.Settings.Security; public class EmailValidationSettings : ISettings { diff --git a/src/GuruPR.Application/Settings/Security/TokenEncryptionSettings.cs b/src/GuruPR.Application/Common/Settings/Security/TokenEncryptionSettings.cs similarity index 73% rename from src/GuruPR.Application/Settings/Security/TokenEncryptionSettings.cs rename to src/GuruPR.Application/Common/Settings/Security/TokenEncryptionSettings.cs index f31dcfc..e846438 100644 --- a/src/GuruPR.Application/Settings/Security/TokenEncryptionSettings.cs +++ b/src/GuruPR.Application/Common/Settings/Security/TokenEncryptionSettings.cs @@ -1,4 +1,4 @@ -namespace GuruPR.Application.Settings.Security; +namespace GuruPR.Application.Common.Settings.Security; public class TokenEncryptionSettings : ISettings { diff --git a/src/GuruPR.Application/Settings/Security/TokenHashingSettings.cs b/src/GuruPR.Application/Common/Settings/Security/TokenHashingSettings.cs similarity index 72% rename from src/GuruPR.Application/Settings/Security/TokenHashingSettings.cs rename to src/GuruPR.Application/Common/Settings/Security/TokenHashingSettings.cs index b78e2b5..9485d57 100644 --- a/src/GuruPR.Application/Settings/Security/TokenHashingSettings.cs +++ b/src/GuruPR.Application/Common/Settings/Security/TokenHashingSettings.cs @@ -1,4 +1,4 @@ -namespace GuruPR.Application.Settings.Security; +namespace GuruPR.Application.Common.Settings.Security; public class TokenHashingSettings : ISettings { diff --git a/src/GuruPR.Application/Dtos/Agent/UpdateAgentRequest.cs b/src/GuruPR.Application/Dtos/Agent/UpdateAgentRequest.cs deleted file mode 100644 index 37af5d4..0000000 --- a/src/GuruPR.Application/Dtos/Agent/UpdateAgentRequest.cs +++ /dev/null @@ -1,24 +0,0 @@ -using GuruPR.Domain.Entities.Configurations; -using GuruPR.Domain.Entities.Configurations.Enums; - -namespace GuruPR.Application.Dtos.Agent; - -public class UpdateAgentRequest -{ - public string? Name { get; set; } - public string? AvatarUrl { get; set; } - public string? Description { get; set; } - public string? Instrunctions { get; set; } - - // Tools - public IList? Tools { get; set; } - - // Model Configuration - public ModelConfiguration? ModelConfiguration { get; set; } - - // Memory Settings - public MemoryConfiguration? MemoryConfiguration { get; set; } - - // Metadata - public AgentStatus? Status { get; set; } -} diff --git a/src/GuruPR.Application/Dtos/Conversation/CreateConversationRequest.cs b/src/GuruPR.Application/Dtos/Conversation/CreateConversationRequest.cs deleted file mode 100644 index e2b715c..0000000 --- a/src/GuruPR.Application/Dtos/Conversation/CreateConversationRequest.cs +++ /dev/null @@ -1,12 +0,0 @@ -using GuruPR.Domain.Entities.Conversation; - -namespace GuruPR.Application.Dtos.Conversation; - -public class CreateConversationRequest -{ - public string AgentId { get; set; } = null!; - - public string Title { get; set; } = null!; - - public ConversationMetadata? Metadata { get; set; } = null!; -} diff --git a/src/GuruPR.Application/Dtos/Conversation/UpdateConversationRequest.cs b/src/GuruPR.Application/Dtos/Conversation/UpdateConversationRequest.cs deleted file mode 100644 index 625bdb1..0000000 --- a/src/GuruPR.Application/Dtos/Conversation/UpdateConversationRequest.cs +++ /dev/null @@ -1,16 +0,0 @@ -using GuruPR.Domain.Entities.Conversation; - -namespace GuruPR.Application.Dtos.Conversation; - -public class UpdateConversationRequest -{ - public string? AgentId { get; set; } = null!; - - public string? Title { get; set; } = null!; - - public Dictionary? State { get; set; } = null!; - - public DateTime? UpdatedAt { get; set; } = DateTime.UtcNow; - - public ConversationMetadata? Metadata { get; set; } -} diff --git a/src/GuruPR.Application/Dtos/OAuth/Provider/CreateProviderRequest.cs b/src/GuruPR.Application/Dtos/OAuth/Provider/CreateProviderRequest.cs deleted file mode 100644 index 02baae6..0000000 --- a/src/GuruPR.Application/Dtos/OAuth/Provider/CreateProviderRequest.cs +++ /dev/null @@ -1,19 +0,0 @@ -using GuruPR.Application.Dtos.OAuth.ProviderConnection; -using GuruPR.Domain.Entities.Enums; - -namespace GuruPR.Application.Dtos.OAuth.Provider; - -public class CreateProviderRequest -{ - public required string DisplayName { get; init; } - - public OAuthProviderType ProviderType { get; init; } - - public required string AuthorizationUrl { get; init; } - - public required string TokenUrl { get; init; } - - public List? DefaultScopes { get; init; } = []; - - public List? ProviderConnections { get; init; } = []; -} diff --git a/src/GuruPR.Application/Dtos/OAuth/ProviderConnection/UpdateProviderConnectionRequest.cs b/src/GuruPR.Application/Dtos/OAuth/ProviderConnection/UpdateProviderConnectionRequest.cs deleted file mode 100644 index eaf2df7..0000000 --- a/src/GuruPR.Application/Dtos/OAuth/ProviderConnection/UpdateProviderConnectionRequest.cs +++ /dev/null @@ -1,34 +0,0 @@ -namespace GuruPR.Application.Dtos.OAuth.ProviderConnection; - -public class UpdateProviderConnectionRequest -{ - /// - /// Client ID used for OAuth authentication. - /// - public string? ClientId { get; init; } - - /// - /// Client secret used for OAuth authentication. - /// - public string? ClientSecret { get; init; } - - /// - /// Access token used for authentication. - /// - public string? AccessToken { get; init; } - - /// - /// Refresh token used to renew the access token. - /// - public string? RefreshToken { get; init; } - - /// - /// List of scopes granted for this connection. - /// - public List? Scopes { get; init; } - - /// - /// Expiration date and time of the access token. - /// - public DateTime? AccessExpiresAt { get; init; } -} diff --git a/src/GuruPR.Application/Exceptions/Account/EmailConfirmationException.cs b/src/GuruPR.Application/Exceptions/Account/EmailConfirmationException.cs deleted file mode 100644 index d94ea53..0000000 --- a/src/GuruPR.Application/Exceptions/Account/EmailConfirmationException.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace GuruPR.Application.Exceptions.Account; - -public class EmailConfirmationException(string message) : AccountException(message); diff --git a/src/GuruPR.Application/Exceptions/Account/LoginFailedException.cs b/src/GuruPR.Application/Exceptions/Account/LoginFailedException.cs deleted file mode 100644 index 976accc..0000000 --- a/src/GuruPR.Application/Exceptions/Account/LoginFailedException.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace GuruPR.Application.Exceptions.Account; - -public class LoginFailedException(string message) : AccountException(message); diff --git a/src/GuruPR.Application/Exceptions/Account/LogoutException.cs b/src/GuruPR.Application/Exceptions/Account/LogoutException.cs deleted file mode 100644 index ba9c8fb..0000000 --- a/src/GuruPR.Application/Exceptions/Account/LogoutException.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace GuruPR.Application.Exceptions.Account; - -public class LogoutException(string message) : AccountException(message); diff --git a/src/GuruPR.Application/Exceptions/Account/RegistrationFailedException.cs b/src/GuruPR.Application/Exceptions/Account/RegistrationFailedException.cs index 98ff1c3..b3958cf 100644 --- a/src/GuruPR.Application/Exceptions/Account/RegistrationFailedException.cs +++ b/src/GuruPR.Application/Exceptions/Account/RegistrationFailedException.cs @@ -1,18 +1,14 @@ -using GuruPR.Application.Exceptions.Interfaces; +using GuruPR.Application.Common.Exceptions; namespace GuruPR.Application.Exceptions.Account; -public class RegistrationFailedException : AccountException, IValidationException +public class RegistrationFailedException : ValidationExceptionBase { - public IReadOnlyDictionary> Errors { get; } - public RegistrationFailedException(string message) : base(message) { - Errors = new Dictionary>(); } - public RegistrationFailedException(string message, IReadOnlyDictionary> errors) : base(message) + public RegistrationFailedException(string message, IReadOnlyDictionary> errors) : base(message, errors) { - Errors = errors; } } diff --git a/src/GuruPR.Application/Exceptions/Account/UserNotFoundException.cs b/src/GuruPR.Application/Exceptions/Account/UserNotFoundException.cs index b16aa72..700ab49 100644 --- a/src/GuruPR.Application/Exceptions/Account/UserNotFoundException.cs +++ b/src/GuruPR.Application/Exceptions/Account/UserNotFoundException.cs @@ -1,4 +1,6 @@ -namespace GuruPR.Application.Exceptions.Account; +using GuruPR.Application.Common.Exceptions; + +namespace GuruPR.Application.Exceptions.Account; public class UserNotFoundException : NotFoundException { diff --git a/src/GuruPR.Application/Exceptions/Agent/AgentException.cs b/src/GuruPR.Application/Exceptions/Agent/AgentException.cs deleted file mode 100644 index 3f23b97..0000000 --- a/src/GuruPR.Application/Exceptions/Agent/AgentException.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace GuruPR.Application.Exceptions.Agent; - -public class AgentException(string message) : Exception(message); \ No newline at end of file diff --git a/src/GuruPR.Application/Exceptions/Agent/AgentValidationException.cs b/src/GuruPR.Application/Exceptions/Agent/AgentValidationException.cs deleted file mode 100644 index 0cf5912..0000000 --- a/src/GuruPR.Application/Exceptions/Agent/AgentValidationException.cs +++ /dev/null @@ -1,19 +0,0 @@ - -using GuruPR.Application.Exceptions.Interfaces; - -namespace GuruPR.Application.Exceptions.Agent; - -public class AgentValidationException : AgentException, IValidationException -{ - public IReadOnlyDictionary> Errors { get; } - - public AgentValidationException(string message) : base(message) - { - Errors = new Dictionary>(); - } - - public AgentValidationException(string message, IReadOnlyDictionary> errors) : base(message) - { - Errors = errors; - } -} diff --git a/src/GuruPR.Application/Extensions/ServiceExtensions.cs b/src/GuruPR.Application/Extensions/ServiceExtensions.cs deleted file mode 100644 index f5d6e31..0000000 --- a/src/GuruPR.Application/Extensions/ServiceExtensions.cs +++ /dev/null @@ -1,42 +0,0 @@ -using GuruPR.Application.Interfaces.Application; -using GuruPR.Application.Profiles.Agents; -using GuruPR.Application.Profiles.Conversations; -using GuruPR.Application.Profiles.OAuth; -using GuruPR.Application.Services; -using GuruPR.Application.Services.Account; -using GuruPR.Application.Services.Email; -using GuruPR.Application.Services.OAuth; -using GuruPR.Application.Services.Validators; - -using Microsoft.Extensions.DependencyInjection; - -namespace GuruPR.Application.Extensions; - -public static class ServiceExtensions -{ - public static void AddApplicationServices(this IServiceCollection services) - { - services.AddAutoMapper(config => - { - config.AllowNullCollections = true; - - config.AddProfile(); - config.AddProfile(); - config.AddProfile(); - config.AddProfile(); - }); - - services.AddScoped(); - services.AddScoped(); - - services.AddScoped(); - - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - - services.AddScoped(); - services.AddScoped(); - } -} diff --git a/src/GuruPR.Application/Features/Account/Commands/ConfirmEmail/ConfirmEmailCommand.cs b/src/GuruPR.Application/Features/Account/Commands/ConfirmEmail/ConfirmEmailCommand.cs new file mode 100644 index 0000000..6f1b830 --- /dev/null +++ b/src/GuruPR.Application/Features/Account/Commands/ConfirmEmail/ConfirmEmailCommand.cs @@ -0,0 +1,5 @@ +using MediatR; + +namespace GuruPR.Application.Features.Account.Commands.ConfirmEmail; + +public record ConfirmEmailCommand(string UserId, string Token) : IRequest; diff --git a/src/GuruPR.Application/Features/Account/Commands/ConfirmEmail/ConfirmEmailHandler.cs b/src/GuruPR.Application/Features/Account/Commands/ConfirmEmail/ConfirmEmailHandler.cs new file mode 100644 index 0000000..a686428 --- /dev/null +++ b/src/GuruPR.Application/Features/Account/Commands/ConfirmEmail/ConfirmEmailHandler.cs @@ -0,0 +1,45 @@ +using FluentValidation; + +using GuruPR.Application.Common.Extensions.Validation; +using GuruPR.Application.Features.Account.Exceptions; +using GuruPR.Application.Features.Users.Extensions; +using GuruPR.Domain.Entities; + +using MediatR; + +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; + +namespace GuruPR.Application.Features.Account.Commands.ConfirmEmail; + +public class ConfirmEmailHandler : IRequestHandler +{ + private readonly ILogger _logger; + private readonly IValidator _validator; + private readonly UserManager _userManager; + + public ConfirmEmailHandler(ILogger logger, + IValidator validator, + UserManager userManager) + { + _logger = logger; + _validator = validator; + _userManager = userManager; + } + + public async Task Handle(ConfirmEmailCommand request, CancellationToken cancellationToken) + { + await _validator.ThrowIfInvalidAsync(request, + (message, errors) => new AccountValidationException(message, errors)); + var user = await _userManager.GetByIdOrThrowAsync(request.UserId); + + var result = await _userManager.ConfirmEmailAsync(user, request.Token); + if (!result.Succeeded) + { + _logger.LogError("Email confirmation failed for user with ID {UserId}. Errors: {Errors}", request.UserId, + string.Join(", ", result.Errors.Select(e => e.Description))); + + throw new EmailConfirmationException("Email confirmation failed."); + } + } +} diff --git a/src/GuruPR.Application/Features/Account/Commands/ConfirmEmail/ConfirmEmailValidator.cs b/src/GuruPR.Application/Features/Account/Commands/ConfirmEmail/ConfirmEmailValidator.cs new file mode 100644 index 0000000..d63cc20 --- /dev/null +++ b/src/GuruPR.Application/Features/Account/Commands/ConfirmEmail/ConfirmEmailValidator.cs @@ -0,0 +1,15 @@ +using FluentValidation; + +namespace GuruPR.Application.Features.Account.Commands.ConfirmEmail; + +public class ConfirmEmailValidator : AbstractValidator +{ + public ConfirmEmailValidator() + { + RuleFor(command => command.UserId).NotEmpty() + .WithMessage("User ID must not be empty."); + + RuleFor(command => command.Token).NotEmpty() + .WithMessage("Token must not be empty."); + } +} diff --git a/src/GuruPR.Application/Features/Account/Commands/ExternalLogin/ExternalLoginCommand.cs b/src/GuruPR.Application/Features/Account/Commands/ExternalLogin/ExternalLoginCommand.cs new file mode 100644 index 0000000..976c3f4 --- /dev/null +++ b/src/GuruPR.Application/Features/Account/Commands/ExternalLogin/ExternalLoginCommand.cs @@ -0,0 +1,5 @@ +using MediatR; + +namespace GuruPR.Application.Features.Account.Commands.ExternalLogin; + +public record ExternalLoginCommand(string? ReturnUrl) : IRequest; diff --git a/src/GuruPR.Application/Features/Account/Commands/ExternalLogin/ExternalLoginValidator.cs b/src/GuruPR.Application/Features/Account/Commands/ExternalLogin/ExternalLoginValidator.cs new file mode 100644 index 0000000..2f95a60 --- /dev/null +++ b/src/GuruPR.Application/Features/Account/Commands/ExternalLogin/ExternalLoginValidator.cs @@ -0,0 +1,43 @@ +using FluentValidation; + +using GuruPR.Application.Common.Interfaces.Application; +using GuruPR.Application.Exceptions.Account; + +namespace GuruPR.Application.Features.Account.Commands.ExternalLogin; + +public class ExternalLoginValidator : AbstractValidator> +{ + public ExternalLoginValidator(IUrlValidator urlValidator) + { + RuleFor(externalLoginCommand => externalLoginCommand.ReturnUrl) + .NotEmpty() + .WithMessage("Return URL is required.") + .Must((command, returnUrl, context) => + { + try + { + urlValidator.ValidateReturnUrl(returnUrl); + return true; + } + catch (InvalidReturnUrlException exception) + { + context.MessageFormatter.AppendArgument("Error", exception.Message); + } + catch (UntrustedReturnUrlException exception) + { + context.MessageFormatter.AppendArgument("Error", exception.Message); + } + catch (MissingAllowedOriginsException exception) + { + context.MessageFormatter.AppendArgument("Error", exception.Message); + } + catch (Exception) + { + context.MessageFormatter.AppendArgument("Error", "Unexpected error occurred while validating Return URL."); + } + + return false; + }) + .WithMessage("{Error}"); + } +} diff --git a/src/GuruPR.Application/Features/Account/Commands/ExternalLogin/Google/GoogleCallback/GoogleCallbackCommand.cs b/src/GuruPR.Application/Features/Account/Commands/ExternalLogin/Google/GoogleCallback/GoogleCallbackCommand.cs new file mode 100644 index 0000000..c9e2c3a --- /dev/null +++ b/src/GuruPR.Application/Features/Account/Commands/ExternalLogin/Google/GoogleCallback/GoogleCallbackCommand.cs @@ -0,0 +1,3 @@ +namespace GuruPR.Application.Features.Account.Commands.ExternalLogin.Google.GoogleCallback; + +public record GoogleCallbackCommand(string? ReturnUrl) : ExternalLoginCommand(ReturnUrl); diff --git a/src/GuruPR.Application/Features/Account/Commands/ExternalLogin/Google/GoogleCallback/GoogleCallbackHandler.cs b/src/GuruPR.Application/Features/Account/Commands/ExternalLogin/Google/GoogleCallback/GoogleCallbackHandler.cs new file mode 100644 index 0000000..73fdee0 --- /dev/null +++ b/src/GuruPR.Application/Features/Account/Commands/ExternalLogin/Google/GoogleCallback/GoogleCallbackHandler.cs @@ -0,0 +1,35 @@ +using FluentValidation; + +using GuruPR.Application.Common.Extensions.Validation; +using GuruPR.Application.Common.Interfaces.Infrastructure; +using GuruPR.Application.Features.Account.Exceptions; + +using MediatR; + +using Microsoft.Extensions.Logging; + +namespace GuruPR.Application.Features.Account.Commands.ExternalLogin.Google.GoogleCallback; + +public class GoogleCallbackHandler : IRequestHandler +{ + private readonly ILogger _logger; + private readonly IValidator _validator; + private readonly IExternalAuthService _externalAuthService; + + public GoogleCallbackHandler(ILogger logger, + IValidator validator, + IExternalAuthService externalAuthService) + { + _logger = logger; + _validator = validator; + _externalAuthService = externalAuthService; + } + + public async Task Handle(GoogleCallbackCommand request, CancellationToken cancellationToken) + { + await _validator.ThrowIfInvalidAsync(request, + (message, errors) => new ExternalLoginValidationException(message, errors)); + + return await _externalAuthService.HandleGoogleCallbackAsync(request.ReturnUrl); + } +} diff --git a/src/GuruPR.Application/Features/Account/Commands/ExternalLogin/Google/GoogleCallback/GoogleCallbackValidator.cs b/src/GuruPR.Application/Features/Account/Commands/ExternalLogin/Google/GoogleCallback/GoogleCallbackValidator.cs new file mode 100644 index 0000000..d896bb0 --- /dev/null +++ b/src/GuruPR.Application/Features/Account/Commands/ExternalLogin/Google/GoogleCallback/GoogleCallbackValidator.cs @@ -0,0 +1,10 @@ +using GuruPR.Application.Common.Interfaces.Application; + +namespace GuruPR.Application.Features.Account.Commands.ExternalLogin.Google.GoogleCallback; + +public class GoogleCallbackValidator : ExternalLoginValidator +{ + public GoogleCallbackValidator(IUrlValidator urlValidator) : base(urlValidator) + { + } +} diff --git a/src/GuruPR.Application/Features/Account/Commands/ExternalLogin/Google/GoogleLogin/GoogleLoginCommand.cs b/src/GuruPR.Application/Features/Account/Commands/ExternalLogin/Google/GoogleLogin/GoogleLoginCommand.cs new file mode 100644 index 0000000..20adaf0 --- /dev/null +++ b/src/GuruPR.Application/Features/Account/Commands/ExternalLogin/Google/GoogleLogin/GoogleLoginCommand.cs @@ -0,0 +1,5 @@ +using Microsoft.AspNetCore.Mvc; + +namespace GuruPR.Application.Features.Account.Commands.ExternalLogin.Google.GoogleLogin; + +public record GoogleLoginCommand(string? ReturnUrl) : ExternalLoginCommand(ReturnUrl); diff --git a/src/GuruPR.Application/Features/Account/Commands/ExternalLogin/Google/GoogleLogin/GoogleLoginHandler.cs b/src/GuruPR.Application/Features/Account/Commands/ExternalLogin/Google/GoogleLogin/GoogleLoginHandler.cs new file mode 100644 index 0000000..62d4e80 --- /dev/null +++ b/src/GuruPR.Application/Features/Account/Commands/ExternalLogin/Google/GoogleLogin/GoogleLoginHandler.cs @@ -0,0 +1,37 @@ +using FluentValidation; + +using GuruPR.Application.Common.Extensions.Validation; +using GuruPR.Application.Common.Interfaces.Infrastructure; +using GuruPR.Application.Features.Account.Exceptions; + +using MediatR; + +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace GuruPR.Application.Features.Account.Commands.ExternalLogin.Google.GoogleLogin; + +public class GoogleLoginHandler : IRequestHandler +{ + private readonly ILogger _logger; + private readonly IValidator _validator; + private readonly IExternalAuthService _externalAuthService; + + public GoogleLoginHandler(ILogger logger, + IValidator validator, + IExternalAuthService externalAuthService) + { + _logger = logger; + _validator = validator; + _externalAuthService = externalAuthService; + } + + + public async Task Handle(GoogleLoginCommand request, CancellationToken cancellationToken) + { + await _validator.ThrowIfInvalidAsync(request, + (message, errors) => new ExternalLoginValidationException(message, errors)); + + return await _externalAuthService.InitiateGoogleLoginAsync(request.ReturnUrl); + } +} diff --git a/src/GuruPR.Application/Features/Account/Commands/ExternalLogin/Google/GoogleLogin/GoogleLoginValidator.cs b/src/GuruPR.Application/Features/Account/Commands/ExternalLogin/Google/GoogleLogin/GoogleLoginValidator.cs new file mode 100644 index 0000000..49abd7c --- /dev/null +++ b/src/GuruPR.Application/Features/Account/Commands/ExternalLogin/Google/GoogleLogin/GoogleLoginValidator.cs @@ -0,0 +1,12 @@ +using GuruPR.Application.Common.Interfaces.Application; + +using Microsoft.AspNetCore.Mvc; + +namespace GuruPR.Application.Features.Account.Commands.ExternalLogin.Google.GoogleLogin; + +public class GoogleLoginValidator : ExternalLoginValidator +{ + public GoogleLoginValidator(IUrlValidator urlValidator) : base(urlValidator) + { + } +} diff --git a/src/GuruPR.Application/Features/Account/Commands/Login/LoginCommand.cs b/src/GuruPR.Application/Features/Account/Commands/Login/LoginCommand.cs new file mode 100644 index 0000000..fb216f2 --- /dev/null +++ b/src/GuruPR.Application/Features/Account/Commands/Login/LoginCommand.cs @@ -0,0 +1,5 @@ +using MediatR; + +namespace GuruPR.Application.Features.Account.Commands.Login; + +public record LoginCommand(string Email, string Password) : IRequest; diff --git a/src/GuruPR.Application/Features/Account/Commands/Login/LoginHandler.cs b/src/GuruPR.Application/Features/Account/Commands/Login/LoginHandler.cs new file mode 100644 index 0000000..93562cd --- /dev/null +++ b/src/GuruPR.Application/Features/Account/Commands/Login/LoginHandler.cs @@ -0,0 +1,48 @@ +using FluentValidation; + +using GuruPR.Application.Common.Extensions.Validation; +using GuruPR.Application.Common.Interfaces.Infrastructure; +using GuruPR.Application.Features.Account.Exceptions; +using GuruPR.Domain.Entities; + +using MediatR; + +using Microsoft.AspNetCore.Identity; + +namespace GuruPR.Application.Features.Account.Commands.Login; + +public class LoginHandler : IRequestHandler +{ + private readonly ITokenService _tokenService; + private readonly IValidator _validator; + private readonly UserManager _userManager; + + public LoginHandler(ITokenService tokenService, IValidator validator, UserManager userManager) + { + _tokenService = tokenService; + _validator = validator; + _userManager = userManager; + } + + public async Task Handle(LoginCommand request, CancellationToken cancellationToken) + { + await _validator.ThrowIfInvalidAsync(request, + (message, errors) => new AccountValidationException(message, errors)); + + var user = await _userManager.FindByEmailAsync(request.Email); + + if (user == null || !await _userManager.CheckPasswordAsync(user, request.Password)) + { + throw new LoginFailedException("Login failed. Invalid email or password."); + } + + if (user.NeedsRefreshTokenRenewal()) + { + await _tokenService.IssueNewTokenPairAsync(user); + } + else + { + await _tokenService.RenewAccessTokenAsync(user); + } + } +} diff --git a/src/GuruPR.Application/Features/Account/Commands/Login/LoginValidator.cs b/src/GuruPR.Application/Features/Account/Commands/Login/LoginValidator.cs new file mode 100644 index 0000000..b5608ff --- /dev/null +++ b/src/GuruPR.Application/Features/Account/Commands/Login/LoginValidator.cs @@ -0,0 +1,15 @@ +using FluentValidation; + +using GuruPR.Application.Features.Account.Validators.Extensions; + +namespace GuruPR.Application.Features.Account.Commands.Login; + +public class LoginValidator : AbstractValidator +{ + public LoginValidator() + { + RuleFor(loginCommand => loginCommand.Email).ValidEmail(); + + RuleFor(loginCommand => loginCommand.Password).ValidPassword(); + } +} diff --git a/src/GuruPR.Application/Features/Account/Commands/Logout/LogoutCommand.cs b/src/GuruPR.Application/Features/Account/Commands/Logout/LogoutCommand.cs new file mode 100644 index 0000000..2f3244f --- /dev/null +++ b/src/GuruPR.Application/Features/Account/Commands/Logout/LogoutCommand.cs @@ -0,0 +1,10 @@ +using GuruPR.Application.Common.Markers.Interfaces; + +using MediatR; + +namespace GuruPR.Application.Features.Account.Commands.Logout; + +public record LogoutCommand(string RefreshToken) : IRequest, IUserContextCommand +{ + public string UserId { get; set; } = null!; +} diff --git a/src/GuruPR.Application/Features/Account/Commands/Logout/LogoutHandler.cs b/src/GuruPR.Application/Features/Account/Commands/Logout/LogoutHandler.cs new file mode 100644 index 0000000..2cf4482 --- /dev/null +++ b/src/GuruPR.Application/Features/Account/Commands/Logout/LogoutHandler.cs @@ -0,0 +1,63 @@ +using FluentValidation; + +using GuruPR.Application.Common.Extensions.Validation; +using GuruPR.Application.Common.Interfaces.Infrastructure; +using GuruPR.Application.Exceptions.Account; +using GuruPR.Application.Features.Account.Exceptions; +using GuruPR.Application.Features.Users.Extensions; +using GuruPR.Domain.Entities; + +using MediatR; + +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; + +namespace GuruPR.Application.Features.Account.Commands.Logout; + +public class LogoutHandler : IRequestHandler +{ + private readonly ILogger _logger; + private readonly ITokenService _tokenService; + private readonly IHasher _hasher; + private readonly IValidator _validator; + private readonly UserManager _userManager; + + public LogoutHandler(ILogger logger, + ITokenService tokenService, + IHasher hasher, + IValidator validator, + UserManager userManager) + { + _logger = logger; + _tokenService = tokenService; + _hasher = hasher; + _validator = validator; + _userManager = userManager; + } + + + public async Task Handle(LogoutCommand request, CancellationToken cancellationToken) + { + await _validator.ThrowIfInvalidAsync(request, + (message, errors) => new AccountValidationException(message, errors)); + + var user = await _userManager.GetByIdOrThrowAsync(request.UserId); + var isValidRefreshToken = _hasher.Verify(user.RefreshTokenHash, request.RefreshToken); + if (!isValidRefreshToken || user.IsRefreshTokenExpired()) + { + throw new RefreshTokenException("Invalid refresh token."); + } + + user.ClearRefreshToken(); + + var updateResult = await _userManager.UpdateAsync(user); + if (!updateResult.Succeeded) + { + _logger.LogError("Failed to logout user with ID {UserId}. Errors: {Errors}", request.UserId, updateResult.Errors); + + throw new LogoutException("Failed to logout user."); + } + + await _tokenService.RevokeTokensAsync(user); + } +} diff --git a/src/GuruPR.Application/Features/Account/Commands/Logout/LogoutValidator.cs b/src/GuruPR.Application/Features/Account/Commands/Logout/LogoutValidator.cs new file mode 100644 index 0000000..b135512 --- /dev/null +++ b/src/GuruPR.Application/Features/Account/Commands/Logout/LogoutValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; + +using GuruPR.Application.Features.Account.Validators.Extensions; + +namespace GuruPR.Application.Features.Account.Commands.Logout; + +public class LogoutValidator : AbstractValidator +{ + public LogoutValidator() + { + RuleFor(logoutCommand => logoutCommand.RefreshToken).ValidRefreshToken(); + } +} diff --git a/src/GuruPR.Application/Features/Account/Commands/RefreshToken/RefreshTokenCommand.cs b/src/GuruPR.Application/Features/Account/Commands/RefreshToken/RefreshTokenCommand.cs new file mode 100644 index 0000000..0cbeadf --- /dev/null +++ b/src/GuruPR.Application/Features/Account/Commands/RefreshToken/RefreshTokenCommand.cs @@ -0,0 +1,10 @@ +using GuruPR.Application.Common.Markers.Interfaces; + +using MediatR; + +namespace GuruPR.Application.Features.Account.Commands.RefreshToken; + +public record RefreshTokenCommand(string RefreshToken) : IRequest, IUserContextCommand +{ + public string UserId { get; set; } = null!; +} diff --git a/src/GuruPR.Application/Features/Account/Commands/RefreshToken/RefreshTokenHandler.cs b/src/GuruPR.Application/Features/Account/Commands/RefreshToken/RefreshTokenHandler.cs new file mode 100644 index 0000000..bef55e9 --- /dev/null +++ b/src/GuruPR.Application/Features/Account/Commands/RefreshToken/RefreshTokenHandler.cs @@ -0,0 +1,56 @@ + +using FluentValidation; + +using GuruPR.Application.Common.Extensions.Validation; +using GuruPR.Application.Common.Interfaces.Infrastructure; +using GuruPR.Application.Exceptions.Account; +using GuruPR.Application.Features.Account.Exceptions; +using GuruPR.Application.Features.Users.Extensions; +using GuruPR.Domain.Entities; + +using MediatR; + +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; + +namespace GuruPR.Application.Features.Account.Commands.RefreshToken; + +public class RefreshTokenHandler : IRequestHandler +{ + private readonly ILogger _logger; + private readonly IHasher _hasher; + private readonly IValidator _validator; + private readonly ITokenService _tokenService; + private readonly UserManager _userManager; + + public RefreshTokenHandler(ILogger logger, + IHasher hasher, + IValidator validator, + ITokenService tokenService, + UserManager userManager) + { + _logger = logger; + _hasher = hasher; + _validator = validator; + _tokenService = tokenService; + _userManager = userManager; + } + + public async Task Handle(RefreshTokenCommand request, CancellationToken cancellationToken) + { + await _validator.ThrowIfInvalidAsync(request, + (message, errors) => new AccountValidationException(message, errors)); + + var user = await _userManager.GetByIdOrThrowAsync(request.UserId); + + var isValidRefreshTokenHash = _hasher.Verify(user.RefreshTokenHash, request.RefreshToken); + if (!isValidRefreshTokenHash || user.IsRefreshTokenExpired()) + { + _logger.LogError("Invalid or expired refresh token for user with ID {UserId}.", request.UserId); + + throw new RefreshTokenException("Refresh token has expired."); + } + + await _tokenService.RenewAccessTokenAsync(user); + } +} diff --git a/src/GuruPR.Application/Features/Account/Commands/RefreshToken/RefreshTokenValidator.cs b/src/GuruPR.Application/Features/Account/Commands/RefreshToken/RefreshTokenValidator.cs new file mode 100644 index 0000000..505b697 --- /dev/null +++ b/src/GuruPR.Application/Features/Account/Commands/RefreshToken/RefreshTokenValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; + +using GuruPR.Application.Features.Account.Validators.Extensions; + +namespace GuruPR.Application.Features.Account.Commands.RefreshToken; + +public class RefreshTokenValidator : AbstractValidator +{ + public RefreshTokenValidator() + { + RuleFor(command => command.RefreshToken).ValidRefreshToken(); + } +} diff --git a/src/GuruPR.Application/Features/Account/Commands/Register/RegisterCommand.cs b/src/GuruPR.Application/Features/Account/Commands/Register/RegisterCommand.cs new file mode 100644 index 0000000..45c7be2 --- /dev/null +++ b/src/GuruPR.Application/Features/Account/Commands/Register/RegisterCommand.cs @@ -0,0 +1,6 @@ +using MediatR; + +namespace GuruPR.Application.Features.Account.Commands.Register; + +public record RegisterCommand(string FirstName, string LastName, string Email, string Password) : IRequest; + diff --git a/src/GuruPR.Application/Features/Account/Commands/Register/RegisterHandler.cs b/src/GuruPR.Application/Features/Account/Commands/Register/RegisterHandler.cs new file mode 100644 index 0000000..31b09e1 --- /dev/null +++ b/src/GuruPR.Application/Features/Account/Commands/Register/RegisterHandler.cs @@ -0,0 +1,138 @@ +using FluentValidation; + +using GuruPR.Application.Common.Constants; +using GuruPR.Application.Common.Extensions; +using GuruPR.Application.Common.Extensions.Validation; +using GuruPR.Application.Common.Interfaces.Application; +using GuruPR.Application.Common.Interfaces.Persistence; +using GuruPR.Application.Features.Account.Exceptions; +using GuruPR.Application.Features.Users.Exceptions; +using GuruPR.Application.Features.Users.Extensions; +using GuruPR.Domain.Entities; +using GuruPR.Domain.Enums; +using GuruPR.Domain.Extensions.User; + +using MediatR; + +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.UI.Services; +using Microsoft.Extensions.Logging; + +namespace GuruPR.Application.Features.Account.Commands.Register; + +public class RegisterHandler : IRequestHandler +{ + private readonly ILogger _logger; + private readonly IUnitOfWork _unitOfWork; + private readonly IValidator _validator; + private readonly IEmailSender _emailSender; + private readonly IEmailTemplateService _emailTemplateService; + private readonly IAccountLinkGenerator _accountLinkGenerator; + private readonly UserManager _userManager; + + public RegisterHandler(ILogger logger, + IUnitOfWork unitOfWork, + IValidator validator, + IEmailSender emailSender, + IEmailTemplateService emailTemplateService, + IAccountLinkGenerator accountLinkGenerator, + UserManager userManager) + { + _logger = logger; + _unitOfWork = unitOfWork; + _validator = validator; + _emailSender = emailSender; + _emailTemplateService = emailTemplateService; + _accountLinkGenerator = accountLinkGenerator; + _userManager = userManager; + } + + public async Task Handle(RegisterCommand request, CancellationToken cancellationToken) + { + await _validator.ThrowIfInvalidAsync(request, + (message, errors) => new AccountValidationException(message, errors)); + + await EnsureUserDoesNotExistAsync(request.Email); + + await _unitOfWork.BeginUserManagementTransactionAsync(cancellationToken); + + User user; + + try + { + user = await CreateUserAsync(request); + + await AssignRoleAsync(user.Id.ToString(), UserRole.User); + + await _unitOfWork.CommitUserManagementTransactionAsync(cancellationToken); + } + catch (Exception) + { + await _unitOfWork.RollbackUserManagementTransactionAsync(cancellationToken); + + throw; + } + + try + { + await SendConfirmationEmailAsync(user); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to send confirmation email to user with email {Email}", user.Email); + + throw new EmailSendFailedException("Registration succeeded, but failed to send confirmation email. Please contact support."); + } + } + + private async Task EnsureUserDoesNotExistAsync(string email) + { + var user = await _userManager.FindByEmailAsync(email); + + if (user != null) + { + throw new UserAlreadyExistsException($"User with email '{email}' already exists."); + } + } + + private async Task CreateUserAsync(RegisterCommand request) + { + var user = new User + { + FirstName = request.FirstName, + LastName = request.LastName, + Email = request.Email, + UserName = request.Email + }; + + var result = await _userManager.CreateAsync(user, request.Password); + if (!result.Succeeded) + { + result.Errors.ThrowValidationException("User registration failed."); + } + + return user; + } + + private async Task SendConfirmationEmailAsync(User user) + { + var token = await _userManager.GenerateEmailConfirmationTokenAsync(user); + var confirmationLink = _accountLinkGenerator.GenerateConfirmationLink(user.Id, token); + var emailBody = _emailTemplateService.BuildConfirmationEmailBody(user.FirstName, confirmationLink); + + await _emailSender.SendEmailAsync(user.Email ?? throw new EmailSendFailedException("Failed to send confirmation email."), + Constants.ConfirmationEmailTitle, + emailBody); + } + + private async Task AssignRoleAsync(string userId, UserRole userRole) + { + var user = await _userManager.GetByIdOrThrowAsync(userId); + + var result = await _userManager.AddToRoleAsync(user, userRole.ToName()); + if (!result.Succeeded) + { + throw new UserRoleOperationFailedException($"Failed to add role {userRole.ToName()} to user with email {user.Email}"); + } + } +} diff --git a/src/GuruPR.Application/Features/Account/Commands/Register/RegisterValidator.cs b/src/GuruPR.Application/Features/Account/Commands/Register/RegisterValidator.cs new file mode 100644 index 0000000..7150889 --- /dev/null +++ b/src/GuruPR.Application/Features/Account/Commands/Register/RegisterValidator.cs @@ -0,0 +1,19 @@ +using FluentValidation; + +using GuruPR.Application.Features.Account.Validators.Extensions; + +namespace GuruPR.Application.Features.Account.Commands.Register; + +public class RegisterValidator : AbstractValidator +{ + public RegisterValidator() + { + RuleFor(registerCommand => registerCommand.FirstName).ValidFirstName(); + + RuleFor(registerCommand => registerCommand.LastName).ValidLastName(); + + RuleFor(registerCommand => registerCommand.Email).ValidEmail(); + + RuleFor(registerCommand => registerCommand.Password).ValidEmail(); + } +} diff --git a/src/GuruPR.Application/Features/Account/Exceptions/AccountValidationException.cs b/src/GuruPR.Application/Features/Account/Exceptions/AccountValidationException.cs new file mode 100644 index 0000000..57f991a --- /dev/null +++ b/src/GuruPR.Application/Features/Account/Exceptions/AccountValidationException.cs @@ -0,0 +1,14 @@ +using GuruPR.Application.Common.Exceptions; + +namespace GuruPR.Application.Features.Account.Exceptions; + +public class AccountValidationException : ValidationExceptionBase +{ + public AccountValidationException(string message) : base(message) + { + } + + public AccountValidationException(string message, IReadOnlyDictionary> errors) : base(message, errors) + { + } +} diff --git a/src/GuruPR.Application/Features/Account/Exceptions/EmailConfirmationException.cs b/src/GuruPR.Application/Features/Account/Exceptions/EmailConfirmationException.cs new file mode 100644 index 0000000..cd2263f --- /dev/null +++ b/src/GuruPR.Application/Features/Account/Exceptions/EmailConfirmationException.cs @@ -0,0 +1,5 @@ +using GuruPR.Application.Exceptions.Account; + +namespace GuruPR.Application.Features.Account.Exceptions; + +public class EmailConfirmationException(string message) : AccountException(message); diff --git a/src/GuruPR.Application/Features/Account/Exceptions/EmailSendFailedException.cs b/src/GuruPR.Application/Features/Account/Exceptions/EmailSendFailedException.cs new file mode 100644 index 0000000..996cb93 --- /dev/null +++ b/src/GuruPR.Application/Features/Account/Exceptions/EmailSendFailedException.cs @@ -0,0 +1,5 @@ +using GuruPR.Application.Common.Exceptions; + +namespace GuruPR.Application.Features.Account.Exceptions; + +public class EmailSendFailedException(string message) : OperationFailedException(message); diff --git a/src/GuruPR.Application/Features/Account/Exceptions/ExternalLoginValidationException.cs b/src/GuruPR.Application/Features/Account/Exceptions/ExternalLoginValidationException.cs new file mode 100644 index 0000000..cd9bfcb --- /dev/null +++ b/src/GuruPR.Application/Features/Account/Exceptions/ExternalLoginValidationException.cs @@ -0,0 +1,14 @@ +using GuruPR.Application.Common.Exceptions; + +namespace GuruPR.Application.Features.Account.Exceptions; + +public class ExternalLoginValidationException : ValidationExceptionBase +{ + public ExternalLoginValidationException(string message) : base(message) + { + } + + public ExternalLoginValidationException(string message, IReadOnlyDictionary> errors) : base(message, errors) + { + } +} diff --git a/src/GuruPR.Application/Features/Account/Exceptions/LoginFailedException.cs b/src/GuruPR.Application/Features/Account/Exceptions/LoginFailedException.cs new file mode 100644 index 0000000..02bc8e2 --- /dev/null +++ b/src/GuruPR.Application/Features/Account/Exceptions/LoginFailedException.cs @@ -0,0 +1,5 @@ +using GuruPR.Application.Exceptions.Account; + +namespace GuruPR.Application.Features.Account.Exceptions; + +public class LoginFailedException(string message) : AccountException(message); diff --git a/src/GuruPR.Application/Features/Account/Exceptions/LogoutException.cs b/src/GuruPR.Application/Features/Account/Exceptions/LogoutException.cs new file mode 100644 index 0000000..3741dd1 --- /dev/null +++ b/src/GuruPR.Application/Features/Account/Exceptions/LogoutException.cs @@ -0,0 +1,5 @@ +using GuruPR.Application.Exceptions.Account; + +namespace GuruPR.Application.Features.Account.Exceptions; + +public class LogoutException(string message) : AccountException(message); diff --git a/src/GuruPR.Application/Features/Account/Exceptions/RegistrationValidationException.cs b/src/GuruPR.Application/Features/Account/Exceptions/RegistrationValidationException.cs new file mode 100644 index 0000000..7524545 --- /dev/null +++ b/src/GuruPR.Application/Features/Account/Exceptions/RegistrationValidationException.cs @@ -0,0 +1,14 @@ +using GuruPR.Application.Common.Exceptions; + +namespace GuruPR.Application.Features.Account.Exceptions; + +public class RegistrationValidationException : ValidationExceptionBase +{ + public RegistrationValidationException(string message) : base(message) + { + } + + public RegistrationValidationException(string message, IReadOnlyDictionary> errors) : base(message, errors) + { + } +} diff --git a/src/GuruPR.Application/Services/Account/AccountLinkGenerator.cs b/src/GuruPR.Application/Features/Account/Services/AccountLinkGenerator.cs similarity index 91% rename from src/GuruPR.Application/Services/Account/AccountLinkGenerator.cs rename to src/GuruPR.Application/Features/Account/Services/AccountLinkGenerator.cs index 8e00224..fce810b 100644 --- a/src/GuruPR.Application/Services/Account/AccountLinkGenerator.cs +++ b/src/GuruPR.Application/Features/Account/Services/AccountLinkGenerator.cs @@ -1,9 +1,9 @@ -using GuruPR.Application.Interfaces.Application; +using GuruPR.Application.Common.Interfaces.Application; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; -namespace GuruPR.Application.Services.Account; +namespace GuruPR.Application.Features.Account.Services; public class AccountLinkGenerator : IAccountLinkGenerator { diff --git a/src/GuruPR.Application/Features/Account/Validators/Extensions/AccountValidationRules.cs b/src/GuruPR.Application/Features/Account/Validators/Extensions/AccountValidationRules.cs new file mode 100644 index 0000000..a403158 --- /dev/null +++ b/src/GuruPR.Application/Features/Account/Validators/Extensions/AccountValidationRules.cs @@ -0,0 +1,40 @@ +using FluentValidation; + +namespace GuruPR.Application.Features.Account.Validators.Extensions; + +public static class AccountValidationRules +{ + public static IRuleBuilderOptions ValidFirstName(this IRuleBuilder ruleBuilder) + { + return ruleBuilder.NotEmpty() + .WithMessage("First name is required.") + .MinimumLength(2) + .WithMessage("First name must be at least 2 characters long."); + } + + public static IRuleBuilderOptions ValidLastName(this IRuleBuilder ruleBuilder) + { + return ruleBuilder.NotEmpty() + .WithMessage("Last name is required.") + .MinimumLength(2) + .WithMessage("Last name must be at least 2 characters long."); + } + + public static IRuleBuilderOptions ValidEmail(this IRuleBuilder ruleBuilder) + { + return ruleBuilder.NotEmpty() + .WithMessage("Email is required."); + } + + public static IRuleBuilderOptions ValidPassword(this IRuleBuilder ruleBuilder) + { + return ruleBuilder.NotEmpty() + .WithMessage("Password is required."); + } + + public static IRuleBuilderOptions ValidRefreshToken(this IRuleBuilder ruleBuilder) + { + return ruleBuilder.NotEmpty() + .WithMessage("Refresh token is required."); + } +} diff --git a/src/GuruPR.Application/Features/Agents/Commands/CreateAgent/CreateAgentCommand.cs b/src/GuruPR.Application/Features/Agents/Commands/CreateAgent/CreateAgentCommand.cs new file mode 100644 index 0000000..71d7075 --- /dev/null +++ b/src/GuruPR.Application/Features/Agents/Commands/CreateAgent/CreateAgentCommand.cs @@ -0,0 +1,33 @@ +using System.Text.Json.Serialization; + +using GuruPR.Application.Common.Markers.Interfaces; +using GuruPR.Application.Features.Agents.Dtos; +using GuruPR.Domain.Entities.Agents.Configurations; +using GuruPR.Domain.Entities.Agents.Enums; + +using MediatR; + +namespace GuruPR.Application.Features.Agents.Commands.CreateAgent; + +public record CreateAgentCommand : IRequest, IUserContextCommand +{ + public string Name { get; set; } = null!; + public string AvatarUrl { get; set; } = null!; + public string Description { get; set; } = null!; + public string Instructions { get; set; } = null!; + + // Tools + public IList Tools { get; set; } = null!; + + // Model Configuration + public ModelConfiguration ModelConfiguration { get; set; } = null!; + + // Memory Settings + public MemoryConfiguration MemoryConfiguration { get; set; } = null!; + + // Metadata + public AgentStatus Status { get; set; } = AgentStatus.Inactive; + + [JsonIgnore] + public string? UserId { get; set; } = null!; // Populated by MediatR pipeline behavior +} diff --git a/src/GuruPR.Application/Features/Agents/Commands/CreateAgent/CreateAgentHandler.cs b/src/GuruPR.Application/Features/Agents/Commands/CreateAgent/CreateAgentHandler.cs new file mode 100644 index 0000000..70cd042 --- /dev/null +++ b/src/GuruPR.Application/Features/Agents/Commands/CreateAgent/CreateAgentHandler.cs @@ -0,0 +1,40 @@ +using AutoMapper; + +using FluentValidation; + +using GuruPR.Application.Common.Extensions.Validation; +using GuruPR.Application.Common.Interfaces.Persistence; +using GuruPR.Application.Features.Agents.Dtos; +using GuruPR.Application.Features.Agents.Exceptions; +using GuruPR.Domain.Entities.Agents; + +using MediatR; + +namespace GuruPR.Application.Features.Agents.Commands.CreateAgent; + +public class CreateAgentHandler : IRequestHandler +{ + private readonly IMapper _mapper; + private readonly IUnitOfWork _unitOfWork; + private readonly IValidator _validator; + + public CreateAgentHandler(IMapper mapper, IUnitOfWork unitOfWork, IValidator validator) + { + _mapper = mapper; + _unitOfWork = unitOfWork; + _validator = validator; + } + + public async Task Handle(CreateAgentCommand request, CancellationToken cancellationToken) + { + await _validator.ThrowIfInvalidAsync(request, + (message, errors) => new AgentValidationException(message, errors)); + + var agent = _mapper.Map(request); + var createdAgent = await _unitOfWork.Agents.AddAsync(agent); + + await _unitOfWork.SaveGuruChangesAsync(); + + return _mapper.Map(agent); + } +} diff --git a/src/GuruPR.Application/Features/Agents/Commands/CreateAgent/CreateAgentValidator.cs b/src/GuruPR.Application/Features/Agents/Commands/CreateAgent/CreateAgentValidator.cs new file mode 100644 index 0000000..78abb5d --- /dev/null +++ b/src/GuruPR.Application/Features/Agents/Commands/CreateAgent/CreateAgentValidator.cs @@ -0,0 +1,21 @@ +using FluentValidation; + +using GuruPR.Application.Features.Agents.Validators.Extensions; + +namespace GuruPR.Application.Features.Agents.Commands.CreateAgent; + +public class CreateAgentValidator : AbstractValidator +{ + public CreateAgentValidator() + { + RuleFor(agent => agent.Name).ValidAgentName(); + + RuleFor(agent => agent.AvatarUrl).ValidAvatarUrl(); + + RuleFor(agent => agent.Description).ValidDescription(); + + RuleFor(agent => agent.ModelConfiguration).ValidModelConfiguration(); + + RuleFor(agent => agent.MemoryConfiguration).ValidMemoryConfiguration(); + } +} diff --git a/src/GuruPR.Application/Features/Agents/Commands/DeleteAgent/DeleteAgentCommand.cs b/src/GuruPR.Application/Features/Agents/Commands/DeleteAgent/DeleteAgentCommand.cs new file mode 100644 index 0000000..3859311 --- /dev/null +++ b/src/GuruPR.Application/Features/Agents/Commands/DeleteAgent/DeleteAgentCommand.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; + +using GuruPR.Application.Common.Markers.Interfaces; +using GuruPR.Domain.Entities.Agents; + +using MediatR; + +namespace GuruPR.Application.Features.Agents.Commands.DeleteAgent; + +public record DeleteAgentCommand(string Id) : IRequest, IOwnedEntityRequest +{ + [JsonIgnore] + public string UserId { get; set; } = null!; +} diff --git a/src/GuruPR.Application/Features/Agents/Commands/DeleteAgent/DeleteAgentHandler.cs b/src/GuruPR.Application/Features/Agents/Commands/DeleteAgent/DeleteAgentHandler.cs new file mode 100644 index 0000000..32cc666 --- /dev/null +++ b/src/GuruPR.Application/Features/Agents/Commands/DeleteAgent/DeleteAgentHandler.cs @@ -0,0 +1,38 @@ +using GuruPR.Application.Common.Interfaces.Persistence; +using GuruPR.Application.Features.Agents.Extensions; + +using MediatR; + +using Microsoft.Extensions.Logging; + +namespace GuruPR.Application.Features.Agents.Commands.DeleteAgent; + +public class DeleteAgentHandler : IRequestHandler +{ + private readonly ILogger _logger; + private readonly IUnitOfWork _unitOfWork; + + public DeleteAgentHandler(ILogger logger, IUnitOfWork unitOfWork) + { + _logger = logger; + _unitOfWork = unitOfWork; + } + + public async Task Handle(DeleteAgentCommand request, CancellationToken cancellationToken) + { + var agent = await _unitOfWork.Agents.GetByIdOrThrowAsync(request.Id); + + _unitOfWork.Agents.Delete(agent); + + var result = await _unitOfWork.SaveGuruChangesAsync(); + + if (result == 0) + { + _logger.LogError("Agent {AgentId} was found but SaveChanges affected 0 rows", request.Id); + + throw new InvalidOperationException($"Failed to delete agent {request.Id}"); + } + + return Unit.Value; + } +} diff --git a/src/GuruPR.Application/Features/Agents/Commands/UpdateAgent/UpdateAgentCommand.cs b/src/GuruPR.Application/Features/Agents/Commands/UpdateAgent/UpdateAgentCommand.cs new file mode 100644 index 0000000..76be13b --- /dev/null +++ b/src/GuruPR.Application/Features/Agents/Commands/UpdateAgent/UpdateAgentCommand.cs @@ -0,0 +1,37 @@ +using System.Text.Json.Serialization; + +using GuruPR.Application.Common.Markers.Interfaces; +using GuruPR.Application.Features.Agents.Dtos; +using GuruPR.Domain.Entities.Agents; +using GuruPR.Domain.Entities.Agents.Configurations; +using GuruPR.Domain.Entities.Agents.Enums; + +using MediatR; + +namespace GuruPR.Application.Features.Agents.Commands.UpdateAgent; + +public record UpdateAgentCommand : IRequest, IOwnedEntityRequest +{ + [JsonIgnore] + public string? Id { get; set; } = null!; + + [JsonIgnore] + public string? UserId { get; set; } = null!; + + public string Name { get; set; } = null!; + public string AvatarUrl { get; set; } = null!; + public string Description { get; set; } = null!; + public string Instructions { get; set; } = null!; + + // Tools + public IList Tools { get; set; } = null!; + + // Model Configuration + public ModelConfiguration ModelConfiguration { get; set; } = null!; + + // Memory Settings + public MemoryConfiguration MemoryConfiguration { get; set; } = null!; + + // Metadata + public AgentStatus Status { get; set; } = AgentStatus.Inactive; +} diff --git a/src/GuruPR.Application/Features/Agents/Commands/UpdateAgent/UpdateAgentHandler.cs b/src/GuruPR.Application/Features/Agents/Commands/UpdateAgent/UpdateAgentHandler.cs new file mode 100644 index 0000000..aa94517 --- /dev/null +++ b/src/GuruPR.Application/Features/Agents/Commands/UpdateAgent/UpdateAgentHandler.cs @@ -0,0 +1,59 @@ +using AutoMapper; + +using FluentValidation; + +using GuruPR.Application.Common.Extensions.Validation; +using GuruPR.Application.Common.Interfaces.Persistence; +using GuruPR.Application.Features.Agents.Dtos; +using GuruPR.Application.Features.Agents.Exceptions; +using GuruPR.Application.Features.Agents.Extensions; +using GuruPR.Domain.Entities.Agents.Operations; + +using MediatR; + +using Microsoft.Extensions.Logging; + +namespace GuruPR.Application.Features.Agents.Commands.UpdateAgent; + +public class UpdateAgentHandler : IRequestHandler +{ + private readonly ILogger _logger; + private readonly IMapper _mapper; + private readonly IUnitOfWork _unitOfWork; + private readonly IValidator _validator; + + public UpdateAgentHandler(ILogger logger, + IMapper mapper, + IUnitOfWork unitOfWork, + IValidator validator) + { + _logger = logger; + _mapper = mapper; + _unitOfWork = unitOfWork; + _validator = validator; + } + + public async Task Handle(UpdateAgentCommand request, CancellationToken cancellationToken) + { + await _validator.ThrowIfInvalidAsync(request, + (message, errors) => new AgentValidationException(message, errors)); + + var agent = await _unitOfWork.Agents.GetByIdOrThrowAsync(request.Id!, cancellationToken); + var agentUpdateData = _mapper.Map(request); + + agent.Update(agentUpdateData); + + _unitOfWork.Agents.Update(agent); + + var result = await _unitOfWork.SaveGuruChangesAsync(); + + if (result == 0) + { + _logger.LogError("Agent {AgentId} was found but SaveChanges affected 0 rows", request.Id); + + throw new InvalidOperationException($"Failed to update agent {request.Id}"); + } + + return _mapper.Map(agent); + } +} diff --git a/src/GuruPR.Application/Features/Agents/Commands/UpdateAgent/UpdateAgentValidator.cs b/src/GuruPR.Application/Features/Agents/Commands/UpdateAgent/UpdateAgentValidator.cs new file mode 100644 index 0000000..cf71c6d --- /dev/null +++ b/src/GuruPR.Application/Features/Agents/Commands/UpdateAgent/UpdateAgentValidator.cs @@ -0,0 +1,24 @@ +using FluentValidation; + +using GuruPR.Application.Features.Agents.Validators.Extensions; + +namespace GuruPR.Application.Features.Agents.Commands.UpdateAgent; + +public class UpdateAgentValidator : AbstractValidator +{ + public UpdateAgentValidator() + { + RuleFor(agent => agent.Id).NotEmpty() + .WithMessage("Agent Id is required"); + + RuleFor(agent => agent.Name).ValidAgentName(); + + RuleFor(agent => agent.AvatarUrl).ValidAvatarUrl(); + + RuleFor(agent => agent.Description).ValidDescription(); + + RuleFor(agent => agent.ModelConfiguration).ValidModelConfiguration(); + + RuleFor(agent => agent.MemoryConfiguration).ValidMemoryConfiguration(); + } +} diff --git a/src/GuruPR.Application/Dtos/Agent/AgentDto.cs b/src/GuruPR.Application/Features/Agents/Dtos/AgentDto.cs similarity index 76% rename from src/GuruPR.Application/Dtos/Agent/AgentDto.cs rename to src/GuruPR.Application/Features/Agents/Dtos/AgentDto.cs index 342f79b..ea16407 100644 --- a/src/GuruPR.Application/Dtos/Agent/AgentDto.cs +++ b/src/GuruPR.Application/Features/Agents/Dtos/AgentDto.cs @@ -1,7 +1,7 @@ -using GuruPR.Domain.Entities.Configurations; -using GuruPR.Domain.Entities.Configurations.Enums; +using GuruPR.Domain.Entities.Agents.Configurations; +using GuruPR.Domain.Entities.Agents.Enums; -namespace GuruPR.Application.Dtos.Agent; +namespace GuruPR.Application.Features.Agents.Dtos; public class AgentDto { @@ -21,7 +21,7 @@ public class AgentDto public MemoryConfiguration MemoryConfiguration { get; set; } = null!; // Metadata - public string CreatedBy { get; set; } = null!; + public string UserId { get; set; } = null!; public AgentStatus Status { get; set; } public DateTime CreatedAt { get; set; } } diff --git a/src/GuruPR.Application/Features/Agents/Exceptions/AgentNotFoundException.cs b/src/GuruPR.Application/Features/Agents/Exceptions/AgentNotFoundException.cs new file mode 100644 index 0000000..79df1de --- /dev/null +++ b/src/GuruPR.Application/Features/Agents/Exceptions/AgentNotFoundException.cs @@ -0,0 +1,5 @@ +using GuruPR.Application.Common.Exceptions; + +namespace GuruPR.Application.Features.Agents.Exceptions; + +public class AgentNotFoundException(string message) : NotFoundException(message); diff --git a/src/GuruPR.Application/Features/Agents/Exceptions/AgentValidationException.cs b/src/GuruPR.Application/Features/Agents/Exceptions/AgentValidationException.cs new file mode 100644 index 0000000..d604d3d --- /dev/null +++ b/src/GuruPR.Application/Features/Agents/Exceptions/AgentValidationException.cs @@ -0,0 +1,14 @@ +using GuruPR.Application.Common.Exceptions; + +namespace GuruPR.Application.Features.Agents.Exceptions; + +public class AgentValidationException : ValidationExceptionBase +{ + public AgentValidationException(string message) : base(message) + { + } + + public AgentValidationException(string message, IReadOnlyDictionary> errors) : base(message, errors) + { + } +} diff --git a/src/GuruPR.Application/Features/Agents/Extensions/AgentRepositoryExtensions.cs b/src/GuruPR.Application/Features/Agents/Extensions/AgentRepositoryExtensions.cs new file mode 100644 index 0000000..04c96fb --- /dev/null +++ b/src/GuruPR.Application/Features/Agents/Extensions/AgentRepositoryExtensions.cs @@ -0,0 +1,17 @@ +using GuruPR.Application.Common.Interfaces.Persistence; +using GuruPR.Application.Features.Agents.Exceptions; +using GuruPR.Domain.Entities.Agents; + +namespace GuruPR.Application.Features.Agents.Extensions; + +public static class AgentRepositoryExtensions +{ + public static async Task GetByIdOrThrowAsync(this IAgentRepository agentRepository, + string id, + CancellationToken cancellationToken = default) + { + var agent = await agentRepository.GetByIdAsync(id, cancellationToken); + + return agent ?? throw new AgentNotFoundException($"Agent with the given ID {id} not found."); + } +} diff --git a/src/GuruPR.Application/Features/Agents/Profiles/AgentProfile.cs b/src/GuruPR.Application/Features/Agents/Profiles/AgentProfile.cs new file mode 100644 index 0000000..eb4560e --- /dev/null +++ b/src/GuruPR.Application/Features/Agents/Profiles/AgentProfile.cs @@ -0,0 +1,24 @@ +using AutoMapper; + +using GuruPR.Application.Features.Agents.Commands.CreateAgent; +using GuruPR.Application.Features.Agents.Commands.UpdateAgent; +using GuruPR.Application.Features.Agents.Dtos; +using GuruPR.Domain.Entities.Agents; +using GuruPR.Domain.Entities.Agents.Operations; + +namespace GuruPR.Application.Features.Agents.Profiles; + +public class AgentProfile : Profile +{ + public AgentProfile() + { + CreateMap(); + CreateMap(); + + CreateMap(); + CreateMap(); + + CreateMap(); + CreateMap(); + } +} diff --git a/src/GuruPR.Application/Features/Agents/Queries/GetAgentById/GetAgentByIdHandler.cs b/src/GuruPR.Application/Features/Agents/Queries/GetAgentById/GetAgentByIdHandler.cs new file mode 100644 index 0000000..23790cb --- /dev/null +++ b/src/GuruPR.Application/Features/Agents/Queries/GetAgentById/GetAgentByIdHandler.cs @@ -0,0 +1,28 @@ +using AutoMapper; + +using GuruPR.Application.Common.Interfaces.Persistence; +using GuruPR.Application.Features.Agents.Dtos; +using GuruPR.Application.Features.Agents.Extensions; + +using MediatR; + +namespace GuruPR.Application.Features.Agents.Queries.GetAgentById; + +public class GetAgentByIdHandler : IRequestHandler +{ + private readonly IMapper _mapper; + private readonly IUnitOfWork _unitOfWork; + + public GetAgentByIdHandler(IMapper mapper, IUnitOfWork unitOfWork) + { + _mapper = mapper; + _unitOfWork = unitOfWork; + } + + public async Task Handle(GetAgentByIdQuery request, CancellationToken cancellationToken) + { + var agent = await _unitOfWork.Agents.GetByIdOrThrowAsync(request.Id); + + return _mapper.Map(agent); + } +} diff --git a/src/GuruPR.Application/Features/Agents/Queries/GetAgentById/GetAgentByIdQuery.cs b/src/GuruPR.Application/Features/Agents/Queries/GetAgentById/GetAgentByIdQuery.cs new file mode 100644 index 0000000..0f6196e --- /dev/null +++ b/src/GuruPR.Application/Features/Agents/Queries/GetAgentById/GetAgentByIdQuery.cs @@ -0,0 +1,7 @@ +using GuruPR.Application.Features.Agents.Dtos; + +using MediatR; + +namespace GuruPR.Application.Features.Agents.Queries.GetAgentById; + +public record GetAgentByIdQuery(string Id) : IRequest; \ No newline at end of file diff --git a/src/GuruPR.Application/Features/Agents/Queries/GetAgents/GetAgentsHandler.cs b/src/GuruPR.Application/Features/Agents/Queries/GetAgents/GetAgentsHandler.cs new file mode 100644 index 0000000..c9f1e5d --- /dev/null +++ b/src/GuruPR.Application/Features/Agents/Queries/GetAgents/GetAgentsHandler.cs @@ -0,0 +1,35 @@ +using AutoMapper; +using AutoMapper.QueryableExtensions; + +using GuruPR.Application.Common.Extensions; +using GuruPR.Application.Common.Interfaces.Persistence; +using GuruPR.Application.Common.Models; +using GuruPR.Application.Features.Agents.Dtos; + +using MediatR; + +using Microsoft.EntityFrameworkCore; + +namespace GuruPR.Application.Features.Agents.Queries.GetAgents; + +public class GetAgentsHandler : IRequestHandler> +{ + private readonly IMapper _mapper; + private readonly IUnitOfWork _unitOfWork; + + public GetAgentsHandler(IMapper mapper, IUnitOfWork unitOfWork) + { + _mapper = mapper; + _unitOfWork = unitOfWork; + } + + public async Task> Handle(GetAgentsQuery request, CancellationToken cancellationToken) + { + var agents = await _unitOfWork.Agents.AsQueryable() + .ProjectTo(_mapper.ConfigurationProvider) + .AsNoTracking() + .ToPaginatedListAsync(request.Page, request.PageSize, cancellationToken); + + return agents; + } +} diff --git a/src/GuruPR.Application/Features/Agents/Queries/GetAgents/GetAgentsQuery.cs b/src/GuruPR.Application/Features/Agents/Queries/GetAgents/GetAgentsQuery.cs new file mode 100644 index 0000000..b7ece2d --- /dev/null +++ b/src/GuruPR.Application/Features/Agents/Queries/GetAgents/GetAgentsQuery.cs @@ -0,0 +1,8 @@ +using GuruPR.Application.Common.Models; +using GuruPR.Application.Features.Agents.Dtos; + +using MediatR; + +namespace GuruPR.Application.Features.Agents.Queries.GetAgents; + +public record GetAgentsQuery(int Page, int PageSize) : IRequest>; diff --git a/src/GuruPR.Application/Features/Agents/Validators/Extensions/AgentValidationRules.cs b/src/GuruPR.Application/Features/Agents/Validators/Extensions/AgentValidationRules.cs new file mode 100644 index 0000000..4992092 --- /dev/null +++ b/src/GuruPR.Application/Features/Agents/Validators/Extensions/AgentValidationRules.cs @@ -0,0 +1,43 @@ +using FluentValidation; + +using GuruPR.Domain.Entities.Agents.Configurations; + +namespace GuruPR.Application.Features.Agents.Validators.Extensions; + +public static class AgentValidationRules +{ + public static IRuleBuilderOptions ValidAgentName(this IRuleBuilder ruleBuilder) + { + return ruleBuilder.NotEmpty() + .WithMessage("Name is required."); + } + + public static IRuleBuilderOptions ValidAvatarUrl(this IRuleBuilder ruleBuilder) + { + return ruleBuilder.NotEmpty() + .WithMessage("AvatarUrl is required.") + .Must(url => Uri.IsWellFormedUriString(url, UriKind.Absolute)) + .WithMessage("AvatarUrl must be a valid absolute URL."); + } + + public static IRuleBuilderOptions ValidDescription(this IRuleBuilder ruleBuilder) + { + return ruleBuilder.NotEmpty() + .WithMessage("Description is required.") + .MinimumLength(5) + .WithMessage("Description must be at least 5 characters long."); + } + + public static IRuleBuilderOptions ValidModelConfiguration(this IRuleBuilder ruleBuilder) + { + return ruleBuilder.NotNull() + .WithMessage("Model configuration is required.") + .SetValidator(new ModelConfigurationValidator()); + } + + public static IRuleBuilderOptions ValidMemoryConfiguration(this IRuleBuilder ruleBuilder) + { + return ruleBuilder.SetValidator(new MemoryConfigurationValidator()) + .When(memoryConfiguration => memoryConfiguration != null); + } +} diff --git a/src/GuruPR.Application/Features/Agents/Validators/MemoryConfigurationValidator.cs b/src/GuruPR.Application/Features/Agents/Validators/MemoryConfigurationValidator.cs new file mode 100644 index 0000000..aff4553 --- /dev/null +++ b/src/GuruPR.Application/Features/Agents/Validators/MemoryConfigurationValidator.cs @@ -0,0 +1,42 @@ +using FluentValidation; + +using GuruPR.Domain.Entities.Agents.Configurations; + +namespace GuruPR.Application.Features.Agents.Validators; + +public class MemoryConfigurationValidator : AbstractValidator +{ + public MemoryConfigurationValidator() + { + RuleFor(memoryConfiguration => memoryConfiguration.MaxContextMessages).InclusiveBetween(1, 40) + .WithErrorCode("MaxContextMessages") + .WithMessage("MaxContextMessages must be greater than 0."); + + RuleFor(memoryConfiguration => memoryConfiguration.MaxContextTokens).GreaterThan(0) + .WithErrorCode("MaxContextTokens") + .WithMessage("MaxContextTokens must be greater than 0."); + + RuleFor(memoryConfiguration => memoryConfiguration.MemoryCollectionName).NotEmpty() + .WithErrorCode("MemoryCollectionName") + .WithMessage("MemoryCollectionName is required when UseSemanticMemory is enabled.") + .When(memoryConfiguration => memoryConfiguration.UseSemanticMemory); + + RuleFor(memoryConfiguration => memoryConfiguration.RelevanceThreshold).InclusiveBetween(0, 1) + .WithErrorCode("RelevanceThreshold") + .WithMessage("RelevanceThreshold must be between 0 and 1."); + + RuleFor(memoryConfiguration => memoryConfiguration.MaxRelevantMemories).InclusiveBetween(1, 50) + .WithErrorCode("MaxRelevantMemories") + .WithMessage("MaxRelevantMemories must be between 1 and 50."); + + RuleFor(memoryConfiguration => memoryConfiguration.SummaryThresholdMessages).GreaterThan(0) + .WithErrorCode("SummaryThresholdMessages") + .WithMessage("SummaryThresholdMessages must be greater than 0 when summary is enabled.") + .When(memoryConfiguration => memoryConfiguration.EnableSummary); + + RuleFor(memoryConfiguration => memoryConfiguration.SummaryThresholdMessages).LessThanOrEqualTo(memoryConfiguration => memoryConfiguration.MaxContextMessages) + .WithErrorCode("SummaryThresholdMessages") + .WithMessage("SummaryThresholdMessages cannot exceed MaxContextMessages.") + .When(memoryConfiguration => memoryConfiguration.EnableSummary); + } +} diff --git a/src/GuruPR.Application/Features/Agents/Validators/ModelConfigurationValidator.cs b/src/GuruPR.Application/Features/Agents/Validators/ModelConfigurationValidator.cs new file mode 100644 index 0000000..c92cb54 --- /dev/null +++ b/src/GuruPR.Application/Features/Agents/Validators/ModelConfigurationValidator.cs @@ -0,0 +1,35 @@ +using FluentValidation; + +using GuruPR.Domain.Entities.Agents.Configurations; + +namespace GuruPR.Application.Features.Agents.Validators; + +public class ModelConfigurationValidator : AbstractValidator +{ + public ModelConfigurationValidator() + { + RuleFor(modelConfiguration => modelConfiguration.Provider).NotEmpty() + .WithMessage("Model provider is required."); + + RuleFor(modelConfiguration => modelConfiguration.ModelType).NotEmpty() + .WithMessage("Model type is required."); + + RuleFor(modelConfiguration => modelConfiguration.ModelName).NotEmpty() + .WithMessage("Model name is required."); + + RuleFor(modelConfiguration => modelConfiguration.Temperature).GreaterThanOrEqualTo(0) + .WithMessage("Temperature must be greater than or equal to 0 and preferably less than or equal to 1."); + + RuleFor(modelConfiguration => modelConfiguration.TopP).InclusiveBetween(0, 1) + .WithMessage("TopP must be between 0 and 1."); + + RuleFor(modelConfiguration => modelConfiguration.MaxTokens).GreaterThan(0) + .WithMessage("MaxTokens must be greater than 0."); + + RuleFor(modelConfiguration => modelConfiguration.FrequencyPenalty).InclusiveBetween(0, 2) + .WithMessage("FrequencyPenalty must be between 0 and 2."); + + RuleFor(modelConfiguration => modelConfiguration.PresencePenalty).InclusiveBetween(0, 2) + .WithMessage("PresencePenalty must be between 0 and 2."); + } +} diff --git a/src/GuruPR.Application/Features/Conversations/Commands/ClearConversation/ClearConversationCommand.cs b/src/GuruPR.Application/Features/Conversations/Commands/ClearConversation/ClearConversationCommand.cs new file mode 100644 index 0000000..ca19d41 --- /dev/null +++ b/src/GuruPR.Application/Features/Conversations/Commands/ClearConversation/ClearConversationCommand.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +using GuruPR.Application.Common.Markers.Interfaces; +using GuruPR.Domain.Entities.Conversation; + +using MediatR; + +namespace GuruPR.Application.Features.Conversations.Commands.ClearConversation; + +public record ClearConversationCommand(string Id) : IRequest, IOwnedEntityRequest +{ + + [JsonIgnore] + public string UserId { get; set; } = string.Empty; +} diff --git a/src/GuruPR.Application/Features/Conversations/Commands/ClearConversation/ClearConversationHandler.cs b/src/GuruPR.Application/Features/Conversations/Commands/ClearConversation/ClearConversationHandler.cs new file mode 100644 index 0000000..daad8d3 --- /dev/null +++ b/src/GuruPR.Application/Features/Conversations/Commands/ClearConversation/ClearConversationHandler.cs @@ -0,0 +1,32 @@ +using GuruPR.Application.Common.Interfaces.Persistence; + +using MediatR; + +using Microsoft.Extensions.Logging; + +namespace GuruPR.Application.Features.Conversations.Commands.ClearConversation; + +public class ClearConversationHandler : IRequestHandler +{ + private readonly ILogger _logger; + private readonly IUnitOfWork _unitOfWork; + + public ClearConversationHandler(ILogger logger, IUnitOfWork unitOfWork) + { + _logger = logger; + _unitOfWork = unitOfWork; + } + + public async Task Handle(ClearConversationCommand request, CancellationToken cancellationToken) + { + await _unitOfWork.Messages.DeleteConversationMessagesAsync(request.Id, cancellationToken); + + var result = await _unitOfWork.SaveGuruChangesAsync(cancellationToken); + if (result == 0) + { + _logger.LogWarning("No messages were deleted for conversation {ConversationId}", request.Id); + } + + return Unit.Value; + } +} diff --git a/src/GuruPR.Application/Features/Conversations/Commands/CreateCompletion/CreateCompletionCommand.cs b/src/GuruPR.Application/Features/Conversations/Commands/CreateCompletion/CreateCompletionCommand.cs new file mode 100644 index 0000000..5e91f6f --- /dev/null +++ b/src/GuruPR.Application/Features/Conversations/Commands/CreateCompletion/CreateCompletionCommand.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +using GuruPR.Application.Common.Markers.Interfaces; +using GuruPR.Application.Features.Conversations.Dtos; +using GuruPR.Domain.Entities.Conversation; + +using MediatR; + +namespace GuruPR.Application.Features.Conversations.Commands.CreateCompletion; + +public class CreateCompletionCommand : IRequest, IOwnedEntityRequest +{ + [JsonIgnore] + public string? Id { get; set; } = null!; + + [JsonIgnore] + public string? UserId { get; set; } = null!; + + public string Message { get; set; } = null!; + + public bool StreamResponse { get; set; } +} diff --git a/src/GuruPR.Application/Features/Conversations/Commands/CreateCompletion/CreateCompletionHandler.cs b/src/GuruPR.Application/Features/Conversations/Commands/CreateCompletion/CreateCompletionHandler.cs new file mode 100644 index 0000000..ed9bc7d --- /dev/null +++ b/src/GuruPR.Application/Features/Conversations/Commands/CreateCompletion/CreateCompletionHandler.cs @@ -0,0 +1,179 @@ +using AutoMapper; + +using GuruPR.Application.Common.Interfaces.Infrastructure; +using GuruPR.Application.Common.Interfaces.Persistence; +using GuruPR.Application.Features.Agents.Extensions; +using GuruPR.Application.Features.Conversations.Dtos; +using GuruPR.Application.Features.Conversations.Extensions; +using GuruPR.Application.Features.Conversations.Models.CreateCompletion; +using GuruPR.Domain.Entities.Agents; +using GuruPR.Domain.Entities.Conversation; +using GuruPR.Domain.Entities.Message; + +using MediatR; + +using Microsoft.Extensions.Logging; + +namespace GuruPR.Application.Features.Conversations.Commands.CreateCompletion; + +public class CreateCompletionHandler : IRequestHandler +{ + private readonly ILogger _logger; + private readonly IMapper _mapper; + private readonly IUnitOfWork _unitOfWork; + private readonly IAIChatProvider _aiChatProvider; + + public CreateCompletionHandler(ILogger logger, + IMapper mapper, + IUnitOfWork unitOfWork, + IAIChatProvider aiChatProvider) + { + _logger = logger; + _mapper = mapper; + _unitOfWork = unitOfWork; + _aiChatProvider = aiChatProvider; + } + + public async Task Handle(CreateCompletionCommand request, CancellationToken cancellationToken) + { + var conversation = await _unitOfWork.Conversations.GetByIdOrThrowAsync(request.Id, cancellationToken); + var agent = await _unitOfWork.Agents.GetByIdOrThrowAsync(conversation.AgentId, cancellationToken); + + ValidateAgentIsActive(agent); + + var messages = await _unitOfWork.Messages.GetMessagesAsync(request.Id, + agent.MemoryConfiguration.MaxContextMessages, + cancellationToken); + + var agentExecutionResult = await ExecuteAIProviderAsync(agent, conversation, messages, request.Message, cancellationToken); + + var (userMessage, agentMessage) = CreateMessageEntities(conversation.Id, request.Message, agentExecutionResult); + + await PersistResultsAsync(conversation, userMessage, agentMessage, agent, agentExecutionResult, cancellationToken); + + return _mapper.Map(agentMessage); + } + + private void ValidateAgentIsActive(Agent agent) + { + if (!agent.IsActive()) + { + _logger.LogWarning("Agent with ID {AgentId} is not active", agent.Id); + + throw new InvalidOperationException("The specified agent is not active. Please activate the agent and retry."); + } + } + + private async Task ExecuteAIProviderAsync(Agent agent, + Conversation conversation, + IList messages, + string userMessage, + CancellationToken cancellationToken) + { + try + { + return await _aiChatProvider.ExecuteAsync(agent, conversation, messages, userMessage, cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "AI provider execution failed for Agent {AgentId} in Conversation {ConversationId}", agent.Id, conversation.Id); + + throw; + } + } + + private async Task PersistResultsAsync(Conversation conversation, + Message userMessage, + Message agentMessage, + Agent agent, + AgentExecutionResult agentExecutionResult, + CancellationToken cancellationToken) + { + try + { + // ToDo : Consider moving transaction management to a higher level if multiple operations need to be atomic. + await SaveMessagesAsync(userMessage, agentMessage, cancellationToken); + UpdateConversation(conversation, agentExecutionResult); + await HandleSummaryAsync(agent, conversation, cancellationToken); + + await _unitOfWork.SaveGuruChangesAsync(cancellationToken); + } + catch (Exception ex) + { + await _unitOfWork.RollbackGuruTransactionAsync(cancellationToken); + + _logger.LogError(ex, "Failed to persist results for Conversation {ConversationId}, after completion.", conversation.Id); + + throw; + } + } + + private (Message user, Message assistant) CreateMessageEntities(string conversationId, string userMessageContent, AgentExecutionResult agentExecutionResult) + { + var userMessage = new Message + { + ConversationId = conversationId, + Role = "User", + Content = userMessageContent, + CreatedAt = DateTime.UtcNow, + MetaData = new MessageMetadata + { + TokenCount = agentExecutionResult.InputTokens + } + }; + var agentMessage = new Message + { + ConversationId = conversationId, + Role = "Assistant", + Content = agentExecutionResult.Content, + ToolCalls = agentExecutionResult.ToolCalls, + CreatedAt = DateTime.UtcNow, + MetaData = new MessageMetadata + { + AgentName = agentExecutionResult.AgentName, + TokenCount = agentExecutionResult.OutputTokens, + ProcessingTime = agentExecutionResult.ProcessingTime, + ModelUsed = agentExecutionResult.ModelId + } + }; + + return (userMessage, agentMessage); + } + + private async Task SaveMessagesAsync(Message userMessage, Message agentMessage, CancellationToken cancellationToken = default) + { + await _unitOfWork.Messages.AddAsync(userMessage); + await _unitOfWork.Messages.AddAsync(agentMessage); + } + + private void UpdateConversation(Conversation conversation, AgentExecutionResult agentExecutionResult) + { + conversation.UpdatedAt = DateTime.UtcNow; + conversation.Metadata.TotalMessages += 2; + conversation.Metadata.TotalTokens += agentExecutionResult.TotalTokens; + + _unitOfWork.Conversations.Update(conversation); + } + + private async Task HandleSummaryAsync(Agent agent, Conversation conversation, CancellationToken cancellationToken = default) + { + if (agent.MemoryConfiguration.EnableSummary && + conversation.Metadata.TotalMessages >= agent.MemoryConfiguration.SummaryThresholdMessages) + { + var messages = await _unitOfWork.Messages.GetMessagesAsync(conversation.Id, agent.MemoryConfiguration.MaxContextMessages, cancellationToken); + + var updatedSummary = await _aiChatProvider.GenerateSummaryAsync(messages, conversation.Metadata.Summary, cancellationToken); + + if (string.IsNullOrEmpty(updatedSummary)) + { + _logger.LogWarning("Summary generation returned empty for conversation {ConversationId}", conversation.Id); + + return; + } + + conversation.Metadata.Summary = updatedSummary ?? conversation.Metadata.Summary; + + _unitOfWork.Conversations.Update(conversation); + } + } +} diff --git a/src/GuruPR.Application/Features/Conversations/Commands/CreateConversation/CreateConversationCommand.cs b/src/GuruPR.Application/Features/Conversations/Commands/CreateConversation/CreateConversationCommand.cs new file mode 100644 index 0000000..04e07bc --- /dev/null +++ b/src/GuruPR.Application/Features/Conversations/Commands/CreateConversation/CreateConversationCommand.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +using GuruPR.Application.Common.Markers.Interfaces; +using GuruPR.Application.Features.Conversations.Dtos; + +using MediatR; + +namespace GuruPR.Application.Features.Conversations.Commands.CreateConversation; + +public class CreateConversationCommand : IRequest, IUserContextCommand +{ + [JsonIgnore] + public string? UserId { get; set; } = null!; + + public string AgentId { get; set; } = null!; + + public string Title { get; set; } = null!; +} diff --git a/src/GuruPR.Application/Features/Conversations/Commands/CreateConversation/CreateConversationHandler.cs b/src/GuruPR.Application/Features/Conversations/Commands/CreateConversation/CreateConversationHandler.cs new file mode 100644 index 0000000..7d0b7a1 --- /dev/null +++ b/src/GuruPR.Application/Features/Conversations/Commands/CreateConversation/CreateConversationHandler.cs @@ -0,0 +1,41 @@ +using AutoMapper; + +using FluentValidation; + +using GuruPR.Application.Common.Extensions.Validation; +using GuruPR.Application.Common.Interfaces.Persistence; +using GuruPR.Application.Features.Conversations.Dtos; +using GuruPR.Application.Features.Conversations.Exceptions; +using GuruPR.Domain.Entities.Conversation; + +using MediatR; + +namespace GuruPR.Application.Features.Conversations.Commands.CreateConversation; + +public class CreateConversationHandler : IRequestHandler +{ + private readonly IMapper _mapper; + private readonly IUnitOfWork _unitOfWork; + private readonly IValidator _validator; + + public CreateConversationHandler(IMapper mapper, IUnitOfWork unitOfWork, IValidator validator) + { + _mapper = mapper; + _unitOfWork = unitOfWork; + _validator = validator; + } + + public async Task Handle(CreateConversationCommand request, CancellationToken cancellationToken) + { + await _validator.ThrowIfInvalidAsync(request, + (message, errors) => new ConversationValidationException(message, errors)); + + var conversation = _mapper.Map(request); + + var createdConversation = await _unitOfWork.Conversations.AddAsync(conversation); + + await _unitOfWork.SaveGuruChangesAsync(); + + return _mapper.Map(createdConversation); + } +} diff --git a/src/GuruPR.Application/Features/Conversations/Commands/CreateConversation/CreateConversationValidator.cs b/src/GuruPR.Application/Features/Conversations/Commands/CreateConversation/CreateConversationValidator.cs new file mode 100644 index 0000000..faeb762 --- /dev/null +++ b/src/GuruPR.Application/Features/Conversations/Commands/CreateConversation/CreateConversationValidator.cs @@ -0,0 +1,16 @@ +using FluentValidation; + +using GuruPR.Application.Common.Interfaces.Persistence; +using GuruPR.Application.Features.Conversations.Validators.Extensions; + +namespace GuruPR.Application.Features.Conversations.Commands.CreateConversation; + +public class CreateConversationValidator : AbstractValidator +{ + public CreateConversationValidator(IAgentRepository agentRepository) + { + RuleFor(conversation => conversation.AgentId).ValidConversationAgentId(agentRepository); + + RuleFor(conversation => conversation.Title).ValidConversationTitle(); + } +} diff --git a/src/GuruPR.Application/Features/Conversations/Commands/DeleteConversation/DeleteConversationCommand.cs b/src/GuruPR.Application/Features/Conversations/Commands/DeleteConversation/DeleteConversationCommand.cs new file mode 100644 index 0000000..d748aae --- /dev/null +++ b/src/GuruPR.Application/Features/Conversations/Commands/DeleteConversation/DeleteConversationCommand.cs @@ -0,0 +1,11 @@ +using GuruPR.Application.Common.Markers.Interfaces; +using GuruPR.Domain.Entities.Conversation; + +using MediatR; + +namespace GuruPR.Application.Features.Conversations.Commands.DeleteConversation; + +public record DeleteConversationCommand(string Id) : IRequest, IOwnedEntityRequest +{ + public string UserId { get; set; } = null!; +} diff --git a/src/GuruPR.Application/Features/Conversations/Commands/DeleteConversation/DeleteConversationHandler.cs b/src/GuruPR.Application/Features/Conversations/Commands/DeleteConversation/DeleteConversationHandler.cs new file mode 100644 index 0000000..6260c13 --- /dev/null +++ b/src/GuruPR.Application/Features/Conversations/Commands/DeleteConversation/DeleteConversationHandler.cs @@ -0,0 +1,52 @@ +using GuruPR.Application.Common.Interfaces.Persistence; +using GuruPR.Application.Features.Conversations.Extensions; + +using MediatR; + +using Microsoft.Extensions.Logging; + +namespace GuruPR.Application.Features.Conversations.Commands.DeleteConversation; + +public class DeleteConversationHandler : IRequestHandler +{ + private readonly ILogger _logger; + private readonly IUnitOfWork _unitOfWork; + + public DeleteConversationHandler(ILogger logger, IUnitOfWork unitOfWork) + { + _logger = logger; + _unitOfWork = unitOfWork; + } + + public async Task Handle(DeleteConversationCommand request, CancellationToken cancellationToken) + { + await _unitOfWork.BeginGuruTransactionAsync(); + + try + { + await _unitOfWork.Messages.DeleteConversationMessagesAsync(request.Id, cancellationToken); + + var conversation = await _unitOfWork.Conversations.GetByIdOrThrowAsync(request.Id, cancellationToken); + + _unitOfWork.Conversations.Delete(conversation); + + var result = await _unitOfWork.SaveGuruChangesAsync(); + if (result == 0) + { + _logger.LogError("Conversation {ConversationId} was found but SaveChanges affected 0 rows", request.Id); + + throw new InvalidOperationException($"Failed to delete conversation {request.Id}"); + } + + await _unitOfWork.CommitGuruTransactionAsync(); + } + catch + { + await _unitOfWork.RollbackGuruTransactionAsync(); + + throw; + } + + return Unit.Value; + } +} diff --git a/src/GuruPR.Application/Features/Conversations/Commands/UpdateConversation/UpdateConversationCommand.cs b/src/GuruPR.Application/Features/Conversations/Commands/UpdateConversation/UpdateConversationCommand.cs new file mode 100644 index 0000000..70b00e2 --- /dev/null +++ b/src/GuruPR.Application/Features/Conversations/Commands/UpdateConversation/UpdateConversationCommand.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +using GuruPR.Application.Common.Markers.Interfaces; +using GuruPR.Application.Features.Conversations.Dtos; +using GuruPR.Domain.Entities.Conversation; + +using MediatR; + +namespace GuruPR.Application.Features.Conversations.Commands.UpdateConversation; + +public record UpdateConversationCommand : IRequest, IOwnedEntityRequest +{ + [JsonIgnore] + public string? Id { get; set; } = null!; + + [JsonIgnore] + public string? UserId { get; set; } = null!; + + public string AgentId { get; set; } = null!; + + public string Title { get; set; } = null!; +} diff --git a/src/GuruPR.Application/Features/Conversations/Commands/UpdateConversation/UpdateConversationHandler.cs b/src/GuruPR.Application/Features/Conversations/Commands/UpdateConversation/UpdateConversationHandler.cs new file mode 100644 index 0000000..0436083 --- /dev/null +++ b/src/GuruPR.Application/Features/Conversations/Commands/UpdateConversation/UpdateConversationHandler.cs @@ -0,0 +1,58 @@ +using AutoMapper; + +using FluentValidation; + +using GuruPR.Application.Common.Extensions.Validation; +using GuruPR.Application.Common.Interfaces.Persistence; +using GuruPR.Application.Features.Conversations.Dtos; +using GuruPR.Application.Features.Conversations.Exceptions; +using GuruPR.Application.Features.Conversations.Extensions; +using GuruPR.Domain.Entities.Conversation.Operations; + +using MediatR; + +using Microsoft.Extensions.Logging; + +namespace GuruPR.Application.Features.Conversations.Commands.UpdateConversation; + +public class UpdateConversationHandler : IRequestHandler +{ + private readonly ILogger _logger; + private readonly IMapper _mapper; + private readonly IUnitOfWork _unitOfWork; + private readonly IValidator _validator; + + public UpdateConversationHandler(ILogger logger, + IMapper mapper, + IUnitOfWork unitOfWork, + IValidator validator) + { + _logger = logger; + _mapper = mapper; + _unitOfWork = unitOfWork; + _validator = validator; + } + + public async Task Handle(UpdateConversationCommand request, CancellationToken cancellationToken) + { + await _validator.ThrowIfInvalidAsync(request, + (message, errors) => new ConversationValidationException(message, errors)); + + var conversation = await _unitOfWork.Conversations.GetByIdOrThrowAsync(request.Id!, cancellationToken); + var conversationUpdateData = _mapper.Map(request); + + conversation.Update(conversationUpdateData); + + _unitOfWork.Conversations.Update(conversation); + + var result = await _unitOfWork.SaveGuruChangesAsync(cancellationToken); + if (result == 0) + { + _logger.LogError("Conversation {ConversationId} was found but SaveChanges affected 0 rows", request.Id); + + throw new InvalidOperationException($"Failed to update conversation {request.Id}"); + } + + return _mapper.Map(conversation); + } +} diff --git a/src/GuruPR.Application/Features/Conversations/Commands/UpdateConversation/UpdateConversationValidator.cs b/src/GuruPR.Application/Features/Conversations/Commands/UpdateConversation/UpdateConversationValidator.cs new file mode 100644 index 0000000..38d1127 --- /dev/null +++ b/src/GuruPR.Application/Features/Conversations/Commands/UpdateConversation/UpdateConversationValidator.cs @@ -0,0 +1,16 @@ +using FluentValidation; + +using GuruPR.Application.Common.Interfaces.Persistence; +using GuruPR.Application.Features.Conversations.Validators.Extensions; + +namespace GuruPR.Application.Features.Conversations.Commands.UpdateConversation; + +public class UpdateConversationValidator : AbstractValidator +{ + public UpdateConversationValidator(IAgentRepository agentRepository) + { + RuleFor(conversation => conversation.AgentId).ValidConversationAgentId(agentRepository); + + RuleFor(conversation => conversation.Title).ValidConversationTitle(); + } +} diff --git a/src/GuruPR.Application/Dtos/Conversation/ConversationDto.cs b/src/GuruPR.Application/Features/Conversations/Dtos/ConversationDto.cs similarity index 65% rename from src/GuruPR.Application/Dtos/Conversation/ConversationDto.cs rename to src/GuruPR.Application/Features/Conversations/Dtos/ConversationDto.cs index 1c40e44..9a333f5 100644 --- a/src/GuruPR.Application/Dtos/Conversation/ConversationDto.cs +++ b/src/GuruPR.Application/Features/Conversations/Dtos/ConversationDto.cs @@ -1,10 +1,10 @@ using GuruPR.Domain.Entities.Conversation; -namespace GuruPR.Application.Dtos.Conversation; +namespace GuruPR.Application.Features.Conversations.Dtos; public class ConversationDto { - public string Id { get; set; } = Guid.NewGuid().ToString(); + public string Id { get; set; } = null!; public string UserId { get; set; } = null!; @@ -12,7 +12,7 @@ public class ConversationDto public string Title { get; set; } = null!; - public Dictionary State { get; set; } = null!; + public DateTime CreatedAt { get; set; } public DateTime UpdatedAt { get; set; } diff --git a/src/GuruPR.Application/Features/Conversations/Dtos/MessageDto.cs b/src/GuruPR.Application/Features/Conversations/Dtos/MessageDto.cs new file mode 100644 index 0000000..a2a4946 --- /dev/null +++ b/src/GuruPR.Application/Features/Conversations/Dtos/MessageDto.cs @@ -0,0 +1,19 @@ +using GuruPR.Domain.Entities.Message; +using GuruPR.Domain.Entities.Tool; + +namespace GuruPR.Application.Features.Conversations.Dtos; + +public class MessageDto +{ + public string ConversationId { get; set; } = null!; + + public string Role { get; set; } = null!; + + public string Content { get; set; } = null!; + + public List? ToolCalls { get; set; } + + public DateTime CreatedAt { get; set; } + + public MessageMetadata MetaData { get; set; } = null!; +} diff --git a/src/GuruPR.Application/Features/Conversations/Exceptions/ConversationNotFoundException.cs b/src/GuruPR.Application/Features/Conversations/Exceptions/ConversationNotFoundException.cs new file mode 100644 index 0000000..0fb6db7 --- /dev/null +++ b/src/GuruPR.Application/Features/Conversations/Exceptions/ConversationNotFoundException.cs @@ -0,0 +1,5 @@ +using GuruPR.Application.Common.Exceptions; + +namespace GuruPR.Application.Features.Conversations.Exceptions; + +public class ConversationNotFoundException(string message) : NotFoundException(message); diff --git a/src/GuruPR.Application/Features/Conversations/Exceptions/ConversationValidationException.cs b/src/GuruPR.Application/Features/Conversations/Exceptions/ConversationValidationException.cs new file mode 100644 index 0000000..1f7fd99 --- /dev/null +++ b/src/GuruPR.Application/Features/Conversations/Exceptions/ConversationValidationException.cs @@ -0,0 +1,14 @@ +using GuruPR.Application.Common.Exceptions; + +namespace GuruPR.Application.Features.Conversations.Exceptions; + +public class ConversationValidationException : ValidationExceptionBase +{ + public ConversationValidationException(string message) : base(message) + { + } + + public ConversationValidationException(string message, IReadOnlyDictionary> errors) : base(message, errors) + { + } +} diff --git a/src/GuruPR.Application/Features/Conversations/Extensions/ConversationRepositoryExtensions.cs b/src/GuruPR.Application/Features/Conversations/Extensions/ConversationRepositoryExtensions.cs new file mode 100644 index 0000000..3c553a2 --- /dev/null +++ b/src/GuruPR.Application/Features/Conversations/Extensions/ConversationRepositoryExtensions.cs @@ -0,0 +1,17 @@ +using GuruPR.Application.Common.Interfaces.Persistence; +using GuruPR.Application.Features.Conversations.Exceptions; +using GuruPR.Domain.Entities.Conversation; + +namespace GuruPR.Application.Features.Conversations.Extensions; + +public static class ConversationRepositoryExtensions +{ + public static async Task GetByIdOrThrowAsync(this IConversationRepository conversationRepository, + string id, + CancellationToken cancellationToken) + { + var conversation = await conversationRepository.GetByIdAsync(id, cancellationToken); + + return conversation ?? throw new ConversationNotFoundException($"Conversation with the given ID {id} not found."); + } +} diff --git a/src/GuruPR.Application/Interfaces/Infrastructure/SemanticKernel/Models/AgentExecutionResult.cs b/src/GuruPR.Application/Features/Conversations/Models/CreateCompletion/AgentExecutionResult.cs similarity index 86% rename from src/GuruPR.Application/Interfaces/Infrastructure/SemanticKernel/Models/AgentExecutionResult.cs rename to src/GuruPR.Application/Features/Conversations/Models/CreateCompletion/AgentExecutionResult.cs index c450797..d28b507 100644 --- a/src/GuruPR.Application/Interfaces/Infrastructure/SemanticKernel/Models/AgentExecutionResult.cs +++ b/src/GuruPR.Application/Features/Conversations/Models/CreateCompletion/AgentExecutionResult.cs @@ -1,6 +1,6 @@ using GuruPR.Domain.Entities.Tool; -namespace GuruPR.Application.Interfaces.Infrastructure.SemanticKernel.Models; +namespace GuruPR.Application.Features.Conversations.Models.CreateCompletion; public class AgentExecutionResult { diff --git a/src/GuruPR.Application/Interfaces/Infrastructure/SemanticKernel/Models/ToolCallTraceEvent.cs b/src/GuruPR.Application/Features/Conversations/Models/ToolCalls/ToolCallTraceEvent.cs similarity index 62% rename from src/GuruPR.Application/Interfaces/Infrastructure/SemanticKernel/Models/ToolCallTraceEvent.cs rename to src/GuruPR.Application/Features/Conversations/Models/ToolCalls/ToolCallTraceEvent.cs index bc384d5..c4eef10 100644 --- a/src/GuruPR.Application/Interfaces/Infrastructure/SemanticKernel/Models/ToolCallTraceEvent.cs +++ b/src/GuruPR.Application/Features/Conversations/Models/ToolCalls/ToolCallTraceEvent.cs @@ -1,4 +1,4 @@ -namespace GuruPR.Application.Interfaces.Infrastructure.SemanticKernel.Models; +namespace GuruPR.Application.Features.Conversations.Models.ToolCalls; public record ToolCallTraceEvent ( diff --git a/src/GuruPR.Application/Interfaces/Infrastructure/SemanticKernel/Models/ToolTraceBuffer.cs b/src/GuruPR.Application/Features/Conversations/Models/ToolCalls/ToolTraceBuffer.cs similarity index 53% rename from src/GuruPR.Application/Interfaces/Infrastructure/SemanticKernel/Models/ToolTraceBuffer.cs rename to src/GuruPR.Application/Features/Conversations/Models/ToolCalls/ToolTraceBuffer.cs index fb3916a..d4ba1c0 100644 --- a/src/GuruPR.Application/Interfaces/Infrastructure/SemanticKernel/Models/ToolTraceBuffer.cs +++ b/src/GuruPR.Application/Features/Conversations/Models/ToolCalls/ToolTraceBuffer.cs @@ -1,4 +1,4 @@ -namespace GuruPR.Application.Interfaces.Infrastructure.SemanticKernel.Models; +namespace GuruPR.Application.Features.Conversations.Models.ToolCalls; public class ToolTraceBuffer { diff --git a/src/GuruPR.Application/Features/Conversations/Profiles/ConversationProfile.cs b/src/GuruPR.Application/Features/Conversations/Profiles/ConversationProfile.cs new file mode 100644 index 0000000..a7bd789 --- /dev/null +++ b/src/GuruPR.Application/Features/Conversations/Profiles/ConversationProfile.cs @@ -0,0 +1,21 @@ +using AutoMapper; + +using GuruPR.Application.Features.Conversations.Commands.CreateConversation; +using GuruPR.Application.Features.Conversations.Commands.UpdateConversation; +using GuruPR.Application.Features.Conversations.Dtos; +using GuruPR.Domain.Entities.Conversation; +using GuruPR.Domain.Entities.Conversation.Operations; + +namespace GuruPR.Application.Features.Conversations.Profiles; + +public class ConversationProfile : Profile +{ + public ConversationProfile() + { + CreateMap().ReverseMap(); + + CreateMap().ReverseMap(); + + CreateMap().ReverseMap(); + } +} diff --git a/src/GuruPR.Application/Features/Conversations/Profiles/MessageProfile.cs b/src/GuruPR.Application/Features/Conversations/Profiles/MessageProfile.cs new file mode 100644 index 0000000..d52b943 --- /dev/null +++ b/src/GuruPR.Application/Features/Conversations/Profiles/MessageProfile.cs @@ -0,0 +1,15 @@ +using AutoMapper; + +using GuruPR.Application.Features.Conversations.Dtos; +using GuruPR.Domain.Entities.Message; + +namespace GuruPR.Application.Features.Conversations.Profiles; + +public class MessageProfile : Profile +{ + public MessageProfile() + { + + CreateMap().ReverseMap(); + } +} diff --git a/src/GuruPR.Application/Features/Conversations/Queries/GetConversationById/GetConversationByIdHandler.cs b/src/GuruPR.Application/Features/Conversations/Queries/GetConversationById/GetConversationByIdHandler.cs new file mode 100644 index 0000000..91ee602 --- /dev/null +++ b/src/GuruPR.Application/Features/Conversations/Queries/GetConversationById/GetConversationByIdHandler.cs @@ -0,0 +1,27 @@ +using AutoMapper; + +using GuruPR.Application.Common.Interfaces.Persistence; +using GuruPR.Application.Features.Conversations.Dtos; + +using MediatR; + +namespace GuruPR.Application.Features.Conversations.Queries.GetConversationById; + +public class GetConversationByIdHandler : IRequestHandler +{ + private readonly IMapper _mapper; + private readonly IUnitOfWork _unitOfWork; + + public GetConversationByIdHandler(IMapper mapper, IUnitOfWork unitOfWork) + { + _mapper = mapper; + _unitOfWork = unitOfWork; + } + + public async Task Handle(GetConversationByIdQuery request, CancellationToken cancellationToken) + { + var conversation = await _unitOfWork.Conversations.GetByIdAsync(request.Id, cancellationToken); + + return _mapper.Map(conversation); + } +} diff --git a/src/GuruPR.Application/Features/Conversations/Queries/GetConversationById/GetConversationByIdQuery.cs b/src/GuruPR.Application/Features/Conversations/Queries/GetConversationById/GetConversationByIdQuery.cs new file mode 100644 index 0000000..3ca35ea --- /dev/null +++ b/src/GuruPR.Application/Features/Conversations/Queries/GetConversationById/GetConversationByIdQuery.cs @@ -0,0 +1,12 @@ +using GuruPR.Application.Common.Markers.Interfaces; +using GuruPR.Application.Features.Conversations.Dtos; +using GuruPR.Domain.Entities.Conversation; + +using MediatR; + +namespace GuruPR.Application.Features.Conversations.Queries.GetConversationById; + +public record GetConversationByIdQuery(string Id) : IRequest, IOwnedEntityRequest +{ + public string UserId { get; set; } = null!; +} diff --git a/src/GuruPR.Application/Features/Conversations/Queries/GetConversationsByUserId/GetConversationsByUserIdHandler.cs b/src/GuruPR.Application/Features/Conversations/Queries/GetConversationsByUserId/GetConversationsByUserIdHandler.cs new file mode 100644 index 0000000..544a521 --- /dev/null +++ b/src/GuruPR.Application/Features/Conversations/Queries/GetConversationsByUserId/GetConversationsByUserIdHandler.cs @@ -0,0 +1,37 @@ +using AutoMapper; +using AutoMapper.QueryableExtensions; + +using GuruPR.Application.Common.Extensions; +using GuruPR.Application.Common.Interfaces.Persistence; +using GuruPR.Application.Common.Models; +using GuruPR.Application.Features.Conversations.Dtos; + +using MediatR; + +using Microsoft.EntityFrameworkCore; + +namespace GuruPR.Application.Features.Conversations.Queries.GetConversationsByUserId; + +public class GetConversationsByUserIdHandler : IRequestHandler> +{ + private readonly IMapper _mapper; + private readonly IUnitOfWork _unitOfWork; + + public GetConversationsByUserIdHandler(IUnitOfWork unitOfWork, IMapper mapper) + { + _unitOfWork = unitOfWork; + _mapper = mapper; + } + + public Task> Handle(GetConversationsByUserIdQuery request, CancellationToken cancellationToken) + { + var conversations = _unitOfWork.Conversations.AsQueryable() + .ProjectTo(_mapper.ConfigurationProvider) + .AsNoTracking() + .Where(conversation => conversation.UserId == request.UserId) + .OrderByDescending(c => c.UpdatedAt) + .ToPaginatedListAsync(request.Page, request.PageSize, cancellationToken); + + return conversations; + } +} diff --git a/src/GuruPR.Application/Features/Conversations/Queries/GetConversationsByUserId/GetConversationsByUserIdQuery.cs b/src/GuruPR.Application/Features/Conversations/Queries/GetConversationsByUserId/GetConversationsByUserIdQuery.cs new file mode 100644 index 0000000..c5ada2f --- /dev/null +++ b/src/GuruPR.Application/Features/Conversations/Queries/GetConversationsByUserId/GetConversationsByUserIdQuery.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +using GuruPR.Application.Common.Markers.Interfaces; +using GuruPR.Application.Common.Models; +using GuruPR.Application.Features.Conversations.Dtos; + +using MediatR; + +namespace GuruPR.Application.Features.Conversations.Queries.GetConversationsByUserId; + +public record GetConversationsByUserIdQuery(int Page, int PageSize) : IRequest>, IUserContextCommand +{ + [JsonIgnore] + public string? UserId { get; set; } = null!; +} diff --git a/src/GuruPR.Application/Features/Conversations/Queries/GetMessages/GetMessagesHandler.cs b/src/GuruPR.Application/Features/Conversations/Queries/GetMessages/GetMessagesHandler.cs new file mode 100644 index 0000000..13ee9e7 --- /dev/null +++ b/src/GuruPR.Application/Features/Conversations/Queries/GetMessages/GetMessagesHandler.cs @@ -0,0 +1,27 @@ +using AutoMapper; + +using GuruPR.Application.Common.Interfaces.Persistence; +using GuruPR.Application.Features.Conversations.Dtos; + +using MediatR; + +namespace GuruPR.Application.Features.Conversations.Queries.GetMessages; + +public class GetMessagesHandler : IRequestHandler> +{ + private readonly IMapper _mapper; + private readonly IUnitOfWork _unitOfWork; + + public GetMessagesHandler(IUnitOfWork unitOfWork, IMapper mapper) + { + _unitOfWork = unitOfWork; + _mapper = mapper; + } + + public async Task> Handle(GetMessagesQuery request, CancellationToken cancellationToken) + { + var messages = await _unitOfWork.Messages.GetMessagesAsync(request.ConversationId); + + return _mapper.Map>(messages); + } +} diff --git a/src/GuruPR.Application/Features/Conversations/Queries/GetMessages/GetMessagesQuery.cs b/src/GuruPR.Application/Features/Conversations/Queries/GetMessages/GetMessagesQuery.cs new file mode 100644 index 0000000..3b4ec1e --- /dev/null +++ b/src/GuruPR.Application/Features/Conversations/Queries/GetMessages/GetMessagesQuery.cs @@ -0,0 +1,7 @@ +using GuruPR.Application.Features.Conversations.Dtos; + +using MediatR; + +namespace GuruPR.Application.Features.Conversations.Queries.GetMessages; + +public record GetMessagesQuery(string ConversationId) : IRequest>; diff --git a/src/GuruPR.Application/Features/Conversations/Validators/Extensions/ConversationValidationRules.cs b/src/GuruPR.Application/Features/Conversations/Validators/Extensions/ConversationValidationRules.cs new file mode 100644 index 0000000..d4d1e9d --- /dev/null +++ b/src/GuruPR.Application/Features/Conversations/Validators/Extensions/ConversationValidationRules.cs @@ -0,0 +1,37 @@ +using FluentValidation; + +using GuruPR.Application.Common.Interfaces.Persistence; +using GuruPR.Application.Features.Agents.Extensions; + +namespace GuruPR.Application.Features.Conversations.Validators.Extensions; + +public static class ConversationValidationRules +{ + public static IRuleBuilderOptions ValidConversationAgentId(this IRuleBuilder ruleBuilder, IAgentRepository agentRepository) + { + return ruleBuilder.NotEmpty() + .WithMessage("Agent is required.") + .MustAsync(async (agentId, cancellationToken) => + { + try + { + await agentRepository.GetByIdOrThrowAsync(agentId, cancellationToken); + + return true; + } + catch + { + return false; + } + }) + .WithMessage("Agent does not exist."); + + } + + public static IRuleBuilderOptions ValidConversationTitle(this IRuleBuilder ruleBuilder) + { + return ruleBuilder.NotEmpty() + .WithMessage("Conversation title is required."); + + } +} diff --git a/src/GuruPR.Application/Dtos/OAuth/ProviderConnection/CreateProviderConnectionRequest.cs b/src/GuruPR.Application/Features/ProviderConnections/Commands/CreateProviderConnection/CreateProviderConnectionCommand.cs similarity index 53% rename from src/GuruPR.Application/Dtos/OAuth/ProviderConnection/CreateProviderConnectionRequest.cs rename to src/GuruPR.Application/Features/ProviderConnections/Commands/CreateProviderConnection/CreateProviderConnectionCommand.cs index 7ed95b1..13f7aa5 100644 --- a/src/GuruPR.Application/Dtos/OAuth/ProviderConnection/CreateProviderConnectionRequest.cs +++ b/src/GuruPR.Application/Features/ProviderConnections/Commands/CreateProviderConnection/CreateProviderConnectionCommand.cs @@ -1,7 +1,13 @@ -namespace GuruPR.Application.Dtos.OAuth.ProviderConnection; +using GuruPR.Application.Features.ProviderConnections.Dtos; -public class CreateProviderConnectionRequest +using MediatR; + +namespace GuruPR.Application.Features.ProviderConnections.Commands.CreateProviderConnection; + +public record CreateProviderConnectionCommand : IRequest { + public string? ProviderId { get; set; } + public required string ClientId { get; init; } public required string ClientSecret { get; init; } diff --git a/src/GuruPR.Application/Features/ProviderConnections/Commands/CreateProviderConnection/CreateProviderConnectionHandler.cs b/src/GuruPR.Application/Features/ProviderConnections/Commands/CreateProviderConnection/CreateProviderConnectionHandler.cs new file mode 100644 index 0000000..437bc8f --- /dev/null +++ b/src/GuruPR.Application/Features/ProviderConnections/Commands/CreateProviderConnection/CreateProviderConnectionHandler.cs @@ -0,0 +1,42 @@ +using AutoMapper; + +using FluentValidation; + +using GuruPR.Application.Common.Extensions.Validation; +using GuruPR.Application.Common.Interfaces.Persistence; +using GuruPR.Application.Features.ProviderConnections.Dtos; +using GuruPR.Application.Features.ProviderConnections.Exceptions; +using GuruPR.Domain.Entities.ProviderConnection; + +using MediatR; + +namespace GuruPR.Application.Features.ProviderConnections.Commands.CreateProviderConnection; + +public class CreateProviderConnectionHandler : IRequestHandler +{ + private readonly IMapper _mapper; + private readonly IUnitOfWork _unitOfWork; + private readonly IValidator _validator; + + public CreateProviderConnectionHandler(IMapper mapper, IUnitOfWork unitOfWork, IValidator validator) + { + _mapper = mapper; + _unitOfWork = unitOfWork; + _validator = validator; + } + + public async Task Handle(CreateProviderConnectionCommand request, CancellationToken cancellationToken) + { + await _validator.ThrowIfInvalidAsync(request, + (message, errors) => new ProviderConnectionValidationException(message, errors), + cancellationToken); + + var providerConnection = _mapper.Map(request); + + var createdProviderConnection = await _unitOfWork.ProviderConnections.AddAsync(providerConnection, cancellationToken); + + await _unitOfWork.SaveGuruChangesAsync(cancellationToken); + + return _mapper.Map(createdProviderConnection); + } +} diff --git a/src/GuruPR.Application/Features/ProviderConnections/Commands/CreateProviderConnection/CreateProviderConnectionValidator.cs b/src/GuruPR.Application/Features/ProviderConnections/Commands/CreateProviderConnection/CreateProviderConnectionValidator.cs new file mode 100644 index 0000000..cd4f295 --- /dev/null +++ b/src/GuruPR.Application/Features/ProviderConnections/Commands/CreateProviderConnection/CreateProviderConnectionValidator.cs @@ -0,0 +1,17 @@ +using FluentValidation; + +using GuruPR.Application.Features.ProviderConnections.Validators.Extensions; + +namespace GuruPR.Application.Features.ProviderConnections.Commands.CreateProviderConnection; + +public class CreateProviderConnectionValidator : AbstractValidator +{ + public CreateProviderConnectionValidator() + { + RuleFor(providerConnection => providerConnection.ClientId).ValidClientId(); + + RuleFor(providerConnection => providerConnection.ClientSecret).ValidClientSecret(); + + RuleFor(providerConnection => providerConnection.Scopes).ValidScopes(); + } +} diff --git a/src/GuruPR.Application/Features/ProviderConnections/Commands/DeleteProviderConnection/DeleteProviderConnectionCommand.cs b/src/GuruPR.Application/Features/ProviderConnections/Commands/DeleteProviderConnection/DeleteProviderConnectionCommand.cs new file mode 100644 index 0000000..4958a16 --- /dev/null +++ b/src/GuruPR.Application/Features/ProviderConnections/Commands/DeleteProviderConnection/DeleteProviderConnectionCommand.cs @@ -0,0 +1,5 @@ +using MediatR; + +namespace GuruPR.Application.Features.ProviderConnections.Commands.DeleteProviderConnection; + +public record DeleteProviderConnectionCommand(string Id) : IRequest; diff --git a/src/GuruPR.Application/Features/ProviderConnections/Commands/DeleteProviderConnection/DeleteProviderConnectionHandler.cs b/src/GuruPR.Application/Features/ProviderConnections/Commands/DeleteProviderConnection/DeleteProviderConnectionHandler.cs new file mode 100644 index 0000000..cfa98fc --- /dev/null +++ b/src/GuruPR.Application/Features/ProviderConnections/Commands/DeleteProviderConnection/DeleteProviderConnectionHandler.cs @@ -0,0 +1,49 @@ +using FluentValidation; + +using GuruPR.Application.Common.Extensions.Validation; +using GuruPR.Application.Common.Interfaces.Persistence; +using GuruPR.Application.Features.ProviderConnections.Exceptions; +using GuruPR.Application.Features.ProviderConnections.Extensions; +using GuruPR.Application.Features.Providers.Extensions; + +using MediatR; + +using Microsoft.Extensions.Logging; + +namespace GuruPR.Application.Features.ProviderConnections.Commands.DeleteProviderConnection; + +public class DeleteProviderConnectionHandler : IRequestHandler +{ + private readonly ILogger _logger; + private readonly IUnitOfWork _unitOfWork; + private readonly IValidator _validator; + + public DeleteProviderConnectionHandler(ILogger logger, + IUnitOfWork unitOfWork, + IValidator validator) + { + _logger = logger; + _unitOfWork = unitOfWork; + _validator = validator; + } + + public async Task Handle(DeleteProviderConnectionCommand request, CancellationToken cancellationToken) + { + await _validator.ThrowIfInvalidAsync(request, + (message, errors) => new ProviderConnectionValidationException(message, errors), + cancellationToken); + + var provider = await _unitOfWork.ProviderConnections.GetByIdOrThrowAsync(request.Id); + + _unitOfWork.ProviderConnections.Delete(provider); + + var result = await _unitOfWork.SaveGuruChangesAsync(); + + if (result == 0) + { + _logger.LogError("Provider Connection {providerConnectionId} was found but SaveChanges affected 0 rows", request.Id); + + throw new InvalidOperationException($"Failed to delete provider connection {request.Id}"); + } + } +} diff --git a/src/GuruPR.Application/Features/ProviderConnections/Commands/DeleteProviderConnection/DeleteProviderConnectionValidator.cs b/src/GuruPR.Application/Features/ProviderConnections/Commands/DeleteProviderConnection/DeleteProviderConnectionValidator.cs new file mode 100644 index 0000000..84ec35d --- /dev/null +++ b/src/GuruPR.Application/Features/ProviderConnections/Commands/DeleteProviderConnection/DeleteProviderConnectionValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; + +using GuruPR.Application.Features.ProviderConnections.Validators.Extensions; + +namespace GuruPR.Application.Features.ProviderConnections.Commands.DeleteProviderConnection; + +public class DeleteProviderConnectionValidator : AbstractValidator +{ + public DeleteProviderConnectionValidator() + { + RuleFor(command => command.Id).ValidProviderConnectionId(); + } +} diff --git a/src/GuruPR.Application/Features/ProviderConnections/Commands/UpdateProviderConnection/UpdateProviderConnectionCommand.cs b/src/GuruPR.Application/Features/ProviderConnections/Commands/UpdateProviderConnection/UpdateProviderConnectionCommand.cs new file mode 100644 index 0000000..7fee11f --- /dev/null +++ b/src/GuruPR.Application/Features/ProviderConnections/Commands/UpdateProviderConnection/UpdateProviderConnectionCommand.cs @@ -0,0 +1,16 @@ +using GuruPR.Application.Features.ProviderConnections.Dtos; + +using MediatR; + +namespace GuruPR.Application.Features.ProviderConnections.Commands.UpdateProviderConnection; + +public record UpdateProviderConnectionCommand : IRequest +{ + public string? Id { get; set; } + + public required string ClientId { get; init; } + + public required string ClientSecret { get; init; } + + public required List Scopes { get; init; } +} diff --git a/src/GuruPR.Application/Features/ProviderConnections/Commands/UpdateProviderConnection/UpdateProviderConnectionHandler.cs b/src/GuruPR.Application/Features/ProviderConnections/Commands/UpdateProviderConnection/UpdateProviderConnectionHandler.cs new file mode 100644 index 0000000..0f3e8b6 --- /dev/null +++ b/src/GuruPR.Application/Features/ProviderConnections/Commands/UpdateProviderConnection/UpdateProviderConnectionHandler.cs @@ -0,0 +1,61 @@ +using AutoMapper; + +using FluentValidation; + +using GuruPR.Application.Common.Extensions.Validation; +using GuruPR.Application.Common.Interfaces.Persistence; +using GuruPR.Application.Features.ProviderConnections.Dtos; +using GuruPR.Application.Features.ProviderConnections.Exceptions; +using GuruPR.Application.Features.ProviderConnections.Extensions; +using GuruPR.Application.Features.Providers.Extensions; +using GuruPR.Domain.Entities.ProviderConnection.Operations; + +using MediatR; + +using Microsoft.Extensions.Logging; + +namespace GuruPR.Application.Features.ProviderConnections.Commands.UpdateProviderConnection; + +public class UpdateProviderConnectionHandler : IRequestHandler +{ + private readonly ILogger _logger; + private readonly IMapper _mapper; + private readonly IUnitOfWork _unitOfWork; + private readonly IValidator _validator; + + public UpdateProviderConnectionHandler(ILogger logger, + IMapper mapper, + IUnitOfWork unitOfWork, + IValidator validator) + { + _logger = logger; + _mapper = mapper; + _unitOfWork = unitOfWork; + _validator = validator; + } + + public async Task Handle(UpdateProviderConnectionCommand request, CancellationToken cancellationToken) + { + await _validator.ThrowIfInvalidAsync(request, + (message, errors) => new ProviderConnectionValidationException(message, errors), + cancellationToken); + + var providerConnection = await _unitOfWork.ProviderConnections.GetByIdOrThrowAsync(request.Id!, cancellationToken); + var providerConnectionUpdateData = _mapper.Map(request); + + providerConnection.Update(providerConnectionUpdateData); + + _unitOfWork.ProviderConnections.Update(providerConnection); + + var result = await _unitOfWork.SaveGuruChangesAsync(cancellationToken); + + if (result == 0) + { + _logger.LogError("Provider Connection {providerConnectionId} was found but SaveChanges affected 0 rows", request.Id); + + throw new InvalidOperationException($"Failed to delete provider connection {request.Id}"); + } + + return _mapper.Map(providerConnection); + } +} diff --git a/src/GuruPR.Application/Features/ProviderConnections/Commands/UpdateProviderConnection/UpdateProviderConnectionValidator.cs b/src/GuruPR.Application/Features/ProviderConnections/Commands/UpdateProviderConnection/UpdateProviderConnectionValidator.cs new file mode 100644 index 0000000..279032e --- /dev/null +++ b/src/GuruPR.Application/Features/ProviderConnections/Commands/UpdateProviderConnection/UpdateProviderConnectionValidator.cs @@ -0,0 +1,19 @@ +using FluentValidation; + +using GuruPR.Application.Features.ProviderConnections.Validators.Extensions; + +namespace GuruPR.Application.Features.ProviderConnections.Commands.UpdateProviderConnection; + +public class UpdateProviderConnectionValidator : AbstractValidator +{ + public UpdateProviderConnectionValidator() + { + RuleFor(command => command.Id!).ValidProviderConnectionId(); + + RuleFor(command => command.ClientId).ValidClientId(); + + RuleFor(command => command.ClientSecret).ValidClientSecret(); + + RuleFor(command => command.Scopes).ValidScopes(); + } +} diff --git a/src/GuruPR.Application/Dtos/OAuth/ProviderConnection/ProviderConnectionDto.cs b/src/GuruPR.Application/Features/ProviderConnections/Dtos/ProviderConnectionDto.cs similarity index 82% rename from src/GuruPR.Application/Dtos/OAuth/ProviderConnection/ProviderConnectionDto.cs rename to src/GuruPR.Application/Features/ProviderConnections/Dtos/ProviderConnectionDto.cs index b7b0f1d..81788dc 100644 --- a/src/GuruPR.Application/Dtos/OAuth/ProviderConnection/ProviderConnectionDto.cs +++ b/src/GuruPR.Application/Features/ProviderConnections/Dtos/ProviderConnectionDto.cs @@ -1,4 +1,4 @@ -namespace GuruPR.Application.Dtos.OAuth.ProviderConnection; +namespace GuruPR.Application.Features.ProviderConnections.Dtos; public class ProviderConnectionDto { @@ -7,6 +7,11 @@ public class ProviderConnectionDto /// public string Id { get; init; } = null!; + /// + /// Provider identifier associated with this connection. + /// + public string ProviderId { get; init; } = null!; + /// /// Access token used for authentication. /// diff --git a/src/GuruPR.Application/Features/ProviderConnections/Exceptions/ProviderConnectionNotFoundException.cs b/src/GuruPR.Application/Features/ProviderConnections/Exceptions/ProviderConnectionNotFoundException.cs new file mode 100644 index 0000000..36c99f1 --- /dev/null +++ b/src/GuruPR.Application/Features/ProviderConnections/Exceptions/ProviderConnectionNotFoundException.cs @@ -0,0 +1,5 @@ +using GuruPR.Application.Common.Exceptions; + +namespace GuruPR.Application.Features.ProviderConnections.Exceptions; + +public class ProviderConnectionNotFoundException(string message) : NotFoundException(message); diff --git a/src/GuruPR.Application/Features/ProviderConnections/Exceptions/ProviderConnectionValidationException.cs b/src/GuruPR.Application/Features/ProviderConnections/Exceptions/ProviderConnectionValidationException.cs new file mode 100644 index 0000000..b38853d --- /dev/null +++ b/src/GuruPR.Application/Features/ProviderConnections/Exceptions/ProviderConnectionValidationException.cs @@ -0,0 +1,14 @@ +using GuruPR.Application.Common.Exceptions; + +namespace GuruPR.Application.Features.ProviderConnections.Exceptions; + +public class ProviderConnectionValidationException : ValidationExceptionBase +{ + public ProviderConnectionValidationException(string message) : base(message) + { + } + + public ProviderConnectionValidationException(string message, IReadOnlyDictionary> errors) : base(message, errors) + { + } +} diff --git a/src/GuruPR.Application/Features/ProviderConnections/Extensions/ProviderConnectionRepositoryExtensions.cs b/src/GuruPR.Application/Features/ProviderConnections/Extensions/ProviderConnectionRepositoryExtensions.cs new file mode 100644 index 0000000..35c1251 --- /dev/null +++ b/src/GuruPR.Application/Features/ProviderConnections/Extensions/ProviderConnectionRepositoryExtensions.cs @@ -0,0 +1,17 @@ +using GuruPR.Application.Common.Interfaces.Persistence; +using GuruPR.Application.Features.ProviderConnections.Exceptions; +using GuruPR.Domain.Entities.ProviderConnection; + +namespace GuruPR.Application.Features.ProviderConnections.Extensions; + +public static class ProviderConnectionRepositoryExtensions +{ + public static async Task GetByIdOrThrowAsync(this IProviderConnectionRepository providerConnectionRepository, + string id, + CancellationToken cancellationToken = default) + { + var providerConnection = await providerConnectionRepository.GetByIdAsync(id, cancellationToken); + + return providerConnection ?? throw new ProviderConnectionNotFoundException($"Provider connection with the given ID {id} not found."); + } +} diff --git a/src/GuruPR.Application/Features/ProviderConnections/Profiles/ProviderConnectionProfile.cs b/src/GuruPR.Application/Features/ProviderConnections/Profiles/ProviderConnectionProfile.cs new file mode 100644 index 0000000..93202fc --- /dev/null +++ b/src/GuruPR.Application/Features/ProviderConnections/Profiles/ProviderConnectionProfile.cs @@ -0,0 +1,22 @@ +using AutoMapper; + +using GuruPR.Application.Features.ProviderConnections.Commands.CreateProviderConnection; +using GuruPR.Application.Features.ProviderConnections.Commands.UpdateProviderConnection; +using GuruPR.Application.Features.ProviderConnections.Dtos; +using GuruPR.Domain.Entities.ProviderConnection; + +namespace GuruPR.Application.Features.ProviderConnections.Profiles; + +public class ProviderConnectionProfile : Profile +{ + public ProviderConnectionProfile() + { + CreateMap().ReverseMap(); + + CreateMap().ReverseMap(); + + CreateMap() + .ForAllMembers(opts => opts.Condition((src, dest, srcMember) => srcMember != null)); + CreateMap(); + } +} diff --git a/src/GuruPR.Application/Features/ProviderConnections/Queries/GetProviderConnectionById/GetProviderConnectionByIdHandler.cs b/src/GuruPR.Application/Features/ProviderConnections/Queries/GetProviderConnectionById/GetProviderConnectionByIdHandler.cs new file mode 100644 index 0000000..387af3a --- /dev/null +++ b/src/GuruPR.Application/Features/ProviderConnections/Queries/GetProviderConnectionById/GetProviderConnectionByIdHandler.cs @@ -0,0 +1,38 @@ +using AutoMapper; + +using FluentValidation; + +using GuruPR.Application.Common.Extensions.Validation; +using GuruPR.Application.Common.Interfaces.Persistence; +using GuruPR.Application.Features.ProviderConnections.Dtos; +using GuruPR.Application.Features.ProviderConnections.Exceptions; +using GuruPR.Application.Features.ProviderConnections.Extensions; + +using MediatR; + +namespace GuruPR.Application.Features.ProviderConnections.Queries.GetProviderConnectionById; + +public class GetProviderConnectionByIdHandler : IRequestHandler +{ + private readonly IMapper _mapper; + private readonly IUnitOfWork _unitOfWork; + private readonly IValidator _validator; + + public GetProviderConnectionByIdHandler(IMapper mapper, IUnitOfWork unitOfWork, IValidator validator) + { + _mapper = mapper; + _unitOfWork = unitOfWork; + _validator = validator; + } + + public async Task Handle(GetProviderConnectionByIdQuery request, CancellationToken cancellationToken) + { + await _validator.ThrowIfInvalidAsync(request, + (message, errors) => new ProviderConnectionValidationException(message, errors), + cancellationToken); + + var providerConnection = await _unitOfWork.ProviderConnections.GetByIdOrThrowAsync(request.Id, cancellationToken); + + return _mapper.Map(providerConnection); + } +} diff --git a/src/GuruPR.Application/Features/ProviderConnections/Queries/GetProviderConnectionById/GetProviderConnectionByIdQuery.cs b/src/GuruPR.Application/Features/ProviderConnections/Queries/GetProviderConnectionById/GetProviderConnectionByIdQuery.cs new file mode 100644 index 0000000..dcaa384 --- /dev/null +++ b/src/GuruPR.Application/Features/ProviderConnections/Queries/GetProviderConnectionById/GetProviderConnectionByIdQuery.cs @@ -0,0 +1,7 @@ +using GuruPR.Application.Features.ProviderConnections.Dtos; + +using MediatR; + +namespace GuruPR.Application.Features.ProviderConnections.Queries.GetProviderConnectionById; + +public record GetProviderConnectionByIdQuery(string Id) : IRequest; diff --git a/src/GuruPR.Application/Features/ProviderConnections/Queries/GetProviderConnectionById/GetProviderConnectionByIdValidator.cs b/src/GuruPR.Application/Features/ProviderConnections/Queries/GetProviderConnectionById/GetProviderConnectionByIdValidator.cs new file mode 100644 index 0000000..bfcda4b --- /dev/null +++ b/src/GuruPR.Application/Features/ProviderConnections/Queries/GetProviderConnectionById/GetProviderConnectionByIdValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; + +using GuruPR.Application.Common.Extensions.Validation; + +namespace GuruPR.Application.Features.ProviderConnections.Queries.GetProviderConnectionById; + +public class GetProviderConnectionByIdValidator : AbstractValidator +{ + public GetProviderConnectionByIdValidator() + { + RuleFor(query => query.Id).ValidId("Provider connection"); + } +} diff --git a/src/GuruPR.Application/Features/ProviderConnections/Queries/GetProviderConnections/GetProviderConnectionHandler.cs b/src/GuruPR.Application/Features/ProviderConnections/Queries/GetProviderConnections/GetProviderConnectionHandler.cs new file mode 100644 index 0000000..c05e562 --- /dev/null +++ b/src/GuruPR.Application/Features/ProviderConnections/Queries/GetProviderConnections/GetProviderConnectionHandler.cs @@ -0,0 +1,41 @@ +using System.Linq.Expressions; + +using AutoMapper; + +using FluentValidation; + +using GuruPR.Application.Common.Extensions.Validation; +using GuruPR.Application.Common.Interfaces.Persistence; +using GuruPR.Application.Features.ProviderConnections.Dtos; +using GuruPR.Application.Features.ProviderConnections.Exceptions; +using GuruPR.Domain.Entities.ProviderConnection; + +using MediatR; + +namespace GuruPR.Application.Features.ProviderConnections.Queries.GetProviderConnections; + +public class GetProviderConnectionHandler : IRequestHandler> +{ + private readonly IMapper _mapper; + private readonly IUnitOfWork _unitOfWork; + private readonly IValidator _validator; + + public GetProviderConnectionHandler(IMapper mapper, IUnitOfWork unitOfWork, IValidator validator) + { + _mapper = mapper; + _unitOfWork = unitOfWork; + _validator = validator; + } + + public async Task> Handle(GetProviderConnectionsQuery request, CancellationToken cancellationToken) + { + await _validator.ThrowIfInvalidAsync(request, + (message, errors) => new ProviderConnectionValidationException(message, errors), + cancellationToken); + + Expression> predicate = providerConnection => providerConnection.ProviderId == request.ProviderId; + var providerConnections = await _unitOfWork.ProviderConnections.GetAllAsync(predicate, cancellationToken: cancellationToken); + + return _mapper.Map>(providerConnections); + } +} diff --git a/src/GuruPR.Application/Features/ProviderConnections/Queries/GetProviderConnections/GetProviderConnectionsQuery.cs b/src/GuruPR.Application/Features/ProviderConnections/Queries/GetProviderConnections/GetProviderConnectionsQuery.cs new file mode 100644 index 0000000..dff6b5a --- /dev/null +++ b/src/GuruPR.Application/Features/ProviderConnections/Queries/GetProviderConnections/GetProviderConnectionsQuery.cs @@ -0,0 +1,7 @@ +using GuruPR.Application.Features.ProviderConnections.Dtos; + +using MediatR; + +namespace GuruPR.Application.Features.ProviderConnections.Queries.GetProviderConnections; + +public record GetProviderConnectionsQuery(string ProviderId) : IRequest>; diff --git a/src/GuruPR.Application/Features/ProviderConnections/Queries/GetProviderConnections/GetProviderConnectionsValidator.cs b/src/GuruPR.Application/Features/ProviderConnections/Queries/GetProviderConnections/GetProviderConnectionsValidator.cs new file mode 100644 index 0000000..ca79c3c --- /dev/null +++ b/src/GuruPR.Application/Features/ProviderConnections/Queries/GetProviderConnections/GetProviderConnectionsValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; + +using GuruPR.Application.Common.Extensions.Validation; + +namespace GuruPR.Application.Features.ProviderConnections.Queries.GetProviderConnections; + +public class GetProviderConnectionsValidator : AbstractValidator +{ + public GetProviderConnectionsValidator() + { + RuleFor(query => query.ProviderId).ValidId("Provider"); + } +} diff --git a/src/GuruPR.Application/Features/ProviderConnections/Validators/Extensions/ProviderConnectionValidationRules.cs b/src/GuruPR.Application/Features/ProviderConnections/Validators/Extensions/ProviderConnectionValidationRules.cs new file mode 100644 index 0000000..1d58331 --- /dev/null +++ b/src/GuruPR.Application/Features/ProviderConnections/Validators/Extensions/ProviderConnectionValidationRules.cs @@ -0,0 +1,36 @@ +using FluentValidation; + +using GuruPR.Application.Common.Extensions.Validation; + +namespace GuruPR.Application.Features.ProviderConnections.Validators.Extensions; + +public static class ProviderConnectionValidationRules +{ + public static IRuleBuilderOptions ValidProviderId(this IRuleBuilder ruleBuilder) + { + return ruleBuilder.ValidId("Provider"); + } + + public static IRuleBuilderOptions ValidProviderConnectionId(this IRuleBuilder ruleBuilder) + { + return ruleBuilder.ValidId("Provider Connection"); + } + + public static IRuleBuilderOptions ValidClientId(this IRuleBuilder ruleBuilder) + { + return ruleBuilder.NotEmpty() + .WithMessage("Client Id is required."); + } + + public static IRuleBuilderOptions ValidClientSecret(this IRuleBuilder ruleBuilder) + { + return ruleBuilder.NotEmpty() + .WithMessage("Client Secret is required."); + } + + public static IRuleBuilderOptions> ValidScopes(this IRuleBuilder> ruleBuilder) + { + return ruleBuilder.NotEmpty() + .WithMessage("At least one scope is required."); + } +} diff --git a/src/GuruPR.Application/Features/Providers/Commands/CreateProvider/CreateProviderCommand.cs b/src/GuruPR.Application/Features/Providers/Commands/CreateProvider/CreateProviderCommand.cs new file mode 100644 index 0000000..9b30d99 --- /dev/null +++ b/src/GuruPR.Application/Features/Providers/Commands/CreateProvider/CreateProviderCommand.cs @@ -0,0 +1,19 @@ +using GuruPR.Application.Features.Providers.Dtos; +using GuruPR.Domain.Entities.Provider.Enums; + +using MediatR; + +namespace GuruPR.Application.Features.Providers.Commands.CreateProvider; + +public record CreateProviderCommand : IRequest +{ + public required string DisplayName { get; init; } + + public required OAuthProviderType ProviderType { get; init; } + + public required string AuthorizationUrl { get; init; } + + public required string TokenUrl { get; init; } + + public List? DefaultScopes { get; init; } = []; +} diff --git a/src/GuruPR.Application/Features/Providers/Commands/CreateProvider/CreateProviderHandler.cs b/src/GuruPR.Application/Features/Providers/Commands/CreateProvider/CreateProviderHandler.cs new file mode 100644 index 0000000..913efc4 --- /dev/null +++ b/src/GuruPR.Application/Features/Providers/Commands/CreateProvider/CreateProviderHandler.cs @@ -0,0 +1,46 @@ +using AutoMapper; + +using FluentValidation; + +using GuruPR.Application.Common.Extensions.Validation; +using GuruPR.Application.Common.Interfaces.Persistence; +using GuruPR.Application.Features.Providers.Dtos; +using GuruPR.Application.Features.Providers.Exceptions; +using GuruPR.Domain.Entities.Provider; + +using MediatR; + +namespace GuruPR.Application.Features.Providers.Commands.CreateProvider; + +public class CreateProviderHandler : IRequestHandler +{ + private readonly IMapper _mapper; + private readonly IMediator _mediator; + private readonly IUnitOfWork _unitOfWork; + private readonly IValidator _validator; + + public CreateProviderHandler(IMapper mapper, + IMediator mediator, + IUnitOfWork unitOfWork, + IValidator validator) + { + _mapper = mapper; + _mediator = mediator; + _unitOfWork = unitOfWork; + _validator = validator; + } + + public async Task Handle(CreateProviderCommand request, CancellationToken cancellationToken) + { + await _validator.ThrowIfInvalidAsync(request, + (message, errors) => new ProviderValidationException(message, errors), + cancellationToken); + + var provider = _mapper.Map(request); + var createdProvider = await _unitOfWork.Providers.AddAsync(provider, cancellationToken); + + await _unitOfWork.SaveGuruChangesAsync(cancellationToken); + + return _mapper.Map(createdProvider); + } +} diff --git a/src/GuruPR.Application/Features/Providers/Commands/CreateProvider/CreateProviderValidator.cs b/src/GuruPR.Application/Features/Providers/Commands/CreateProvider/CreateProviderValidator.cs new file mode 100644 index 0000000..2ca18b2 --- /dev/null +++ b/src/GuruPR.Application/Features/Providers/Commands/CreateProvider/CreateProviderValidator.cs @@ -0,0 +1,19 @@ +using FluentValidation; + +using GuruPR.Application.Features.Providers.Validators.Extensions; + +namespace GuruPR.Application.Features.Providers.Commands.CreateProvider; + +public class CreateProviderValidator : AbstractValidator +{ + public CreateProviderValidator() + { + RuleFor(command => command.DisplayName).ValidDisplayName(); + + RuleFor(command => command.TokenUrl).ValidTokenUrl(); + + RuleFor(command => command.ProviderType).ValidProviderType(); + + RuleFor(command => command.AuthorizationUrl).ValidAuthorizationUrl(); + } +} diff --git a/src/GuruPR.Application/Features/Providers/Commands/DeleteProvider/DeleteProviderCommand.cs b/src/GuruPR.Application/Features/Providers/Commands/DeleteProvider/DeleteProviderCommand.cs new file mode 100644 index 0000000..433298a --- /dev/null +++ b/src/GuruPR.Application/Features/Providers/Commands/DeleteProvider/DeleteProviderCommand.cs @@ -0,0 +1,5 @@ +using MediatR; + +namespace GuruPR.Application.Features.Providers.Commands.DeleteProvider; + +public record DeleteProviderCommand(string Id) : IRequest; diff --git a/src/GuruPR.Application/Features/Providers/Commands/DeleteProvider/DeleteProviderHandler.cs b/src/GuruPR.Application/Features/Providers/Commands/DeleteProvider/DeleteProviderHandler.cs new file mode 100644 index 0000000..5e89a1c --- /dev/null +++ b/src/GuruPR.Application/Features/Providers/Commands/DeleteProvider/DeleteProviderHandler.cs @@ -0,0 +1,50 @@ +using FluentValidation; + +using GuruPR.Application.Common.Extensions.Validation; +using GuruPR.Application.Common.Interfaces.Persistence; +using GuruPR.Application.Features.Providers.Exceptions; +using GuruPR.Application.Features.Providers.Extensions; + +using MediatR; + +using Microsoft.Extensions.Logging; + +namespace GuruPR.Application.Features.Providers.Commands.DeleteProvider; + +public class DeleteProviderHandler : IRequestHandler +{ + private readonly ILogger _logger; + private readonly IUnitOfWork _unitOfWork; + private readonly IValidator _validator; + + public DeleteProviderHandler(ILogger logger, + IUnitOfWork unitOfWork, + IValidator validator) + { + _logger = logger; + _unitOfWork = unitOfWork; + _validator = validator; + } + + public async Task Handle(DeleteProviderCommand request, CancellationToken cancellationToken) + { + await _validator.ThrowIfInvalidAsync(request, + (message, errors) => new ProviderValidationException(message, errors), + cancellationToken); + + var provider = await _unitOfWork.Providers.GetByIdOrThrowAsync(request.Id, cancellationToken); + + _unitOfWork.Providers.Delete(provider); + + await _unitOfWork.ProviderConnections.DeleteProviderConnectionsByProviderIdAsync(request.Id, cancellationToken); + + var result = await _unitOfWork.SaveGuruChangesAsync(cancellationToken); + + if (result == 0) + { + _logger.LogError("Provider {ProviderId} was found but SaveChanges affected 0 rows", request.Id); + + throw new InvalidOperationException($"Failed to delete provider {request.Id}"); + } + } +} diff --git a/src/GuruPR.Application/Features/Providers/Commands/DeleteProvider/DeleteProviderValidator.cs b/src/GuruPR.Application/Features/Providers/Commands/DeleteProvider/DeleteProviderValidator.cs new file mode 100644 index 0000000..9bcec11 --- /dev/null +++ b/src/GuruPR.Application/Features/Providers/Commands/DeleteProvider/DeleteProviderValidator.cs @@ -0,0 +1,11 @@ +using FluentValidation; + +namespace GuruPR.Application.Features.Providers.Commands.DeleteProvider; + +public class DeleteProviderValidator : AbstractValidator +{ + public DeleteProviderValidator() + { + + } +} diff --git a/src/GuruPR.Application/Features/Providers/Commands/UpdateProvider/UpdateProviderCommand.cs b/src/GuruPR.Application/Features/Providers/Commands/UpdateProvider/UpdateProviderCommand.cs new file mode 100644 index 0000000..3af781c --- /dev/null +++ b/src/GuruPR.Application/Features/Providers/Commands/UpdateProvider/UpdateProviderCommand.cs @@ -0,0 +1,21 @@ +using GuruPR.Application.Features.Providers.Dtos; +using GuruPR.Domain.Entities.Provider.Enums; + +using MediatR; + +namespace GuruPR.Application.Features.Providers.Commands.UpdateProvider; + +public record UpdateProviderCommand : IRequest +{ + public string? Id { get; set; } + + public string? DisplayName { get; init; } + + public OAuthProviderType? ProviderType { get; init; } + + public string? AuthorizationUrl { get; init; } + + public string? TokenUrl { get; init; } + + public List? DefaultScopes { get; init; } +} diff --git a/src/GuruPR.Application/Features/Providers/Commands/UpdateProvider/UpdateProviderHandler.cs b/src/GuruPR.Application/Features/Providers/Commands/UpdateProvider/UpdateProviderHandler.cs new file mode 100644 index 0000000..be59453 --- /dev/null +++ b/src/GuruPR.Application/Features/Providers/Commands/UpdateProvider/UpdateProviderHandler.cs @@ -0,0 +1,58 @@ +using AutoMapper; + +using FluentValidation; + +using GuruPR.Application.Common.Extensions.Validation; +using GuruPR.Application.Common.Interfaces.Persistence; +using GuruPR.Application.Features.Providers.Dtos; +using GuruPR.Application.Features.Providers.Exceptions; +using GuruPR.Application.Features.Providers.Extensions; +using GuruPR.Domain.Entities.Provider.Operations; + +using MediatR; + +using Microsoft.Extensions.Logging; + +namespace GuruPR.Application.Features.Providers.Commands.UpdateProvider; + +public class UpdateProviderHandler : IRequestHandler +{ + private readonly ILogger _logger; + private readonly IMapper _mapper; + private readonly IUnitOfWork _unitOfWork; + private readonly IValidator _validator; + + public UpdateProviderHandler(ILogger logger, + IMapper mapper, + IUnitOfWork unitOfWork, + IValidator validator) + { + _logger = logger; + _mapper = mapper; + _unitOfWork = unitOfWork; + _validator = validator; + } + + public async Task Handle(UpdateProviderCommand request, CancellationToken cancellationToken) + { + await _validator.ThrowIfInvalidAsync(request, + (message, errors) => new ProviderValidationException(message, errors), + cancellationToken); + + var provider = await _unitOfWork.Providers.GetByIdOrThrowAsync(request.Id!, cancellationToken); + var providerUpdateData = _mapper.Map(request); + + provider.Update(providerUpdateData); + + var result = await _unitOfWork.SaveGuruChangesAsync(cancellationToken); + + if (result == 0) + { + _logger.LogError("Provider {ProviderId} was found but SaveChanges affected 0 rows", request.Id); + + throw new InvalidOperationException($"Failed to delete provider {request.Id}"); + } + + return _mapper.Map(provider); + } +} diff --git a/src/GuruPR.Application/Features/Providers/Commands/UpdateProvider/UpdateProviderValidator.cs b/src/GuruPR.Application/Features/Providers/Commands/UpdateProvider/UpdateProviderValidator.cs new file mode 100644 index 0000000..28df188 --- /dev/null +++ b/src/GuruPR.Application/Features/Providers/Commands/UpdateProvider/UpdateProviderValidator.cs @@ -0,0 +1,20 @@ +using FluentValidation; + +using GuruPR.Application.Features.Providers.Validators.Extensions; +using GuruPR.Domain.Entities.Provider.Enums; + +namespace GuruPR.Application.Features.Providers.Commands.UpdateProvider; + +public class UpdateProviderValidator : AbstractValidator +{ + public UpdateProviderValidator() + { + RuleFor(command => command.DisplayName).ValidDisplayName(); + + RuleFor(command => command.TokenUrl).ValidTokenUrl(); + + RuleFor(command => command.ProviderType ?? OAuthProviderType.Other).ValidProviderType(); + + RuleFor(command => command.AuthorizationUrl).ValidAuthorizationUrl(); + } +} diff --git a/src/GuruPR.Application/Dtos/OAuth/Provider/ProviderDto.cs b/src/GuruPR.Application/Features/Providers/Dtos/ProviderDto.cs similarity index 64% rename from src/GuruPR.Application/Dtos/OAuth/Provider/ProviderDto.cs rename to src/GuruPR.Application/Features/Providers/Dtos/ProviderDto.cs index 61d5844..2100b7e 100644 --- a/src/GuruPR.Application/Dtos/OAuth/Provider/ProviderDto.cs +++ b/src/GuruPR.Application/Features/Providers/Dtos/ProviderDto.cs @@ -1,7 +1,6 @@ -using GuruPR.Application.Dtos.OAuth.ProviderConnection; -using GuruPR.Domain.Entities.Enums; +using GuruPR.Domain.Entities.Provider.Enums; -namespace GuruPR.Application.Dtos.OAuth.Provider; +namespace GuruPR.Application.Features.Providers.Dtos; public class ProviderDto { @@ -18,6 +17,4 @@ public class ProviderDto public IReadOnlyList DefaultScopes { get; init; } = []; public DateTime CreatedAt { get; init; } - - public List ProviderConnections { get; init; } = []; } diff --git a/src/GuruPR.Application/Features/Providers/Exceptions/ProviderNotFoundException.cs b/src/GuruPR.Application/Features/Providers/Exceptions/ProviderNotFoundException.cs new file mode 100644 index 0000000..5e0a467 --- /dev/null +++ b/src/GuruPR.Application/Features/Providers/Exceptions/ProviderNotFoundException.cs @@ -0,0 +1,5 @@ +using GuruPR.Application.Common.Exceptions; + +namespace GuruPR.Application.Features.Providers.Exceptions; + +public class ProviderNotFoundException(string message) : NotFoundException(message); diff --git a/src/GuruPR.Application/Features/Providers/Exceptions/ProviderValidationException.cs b/src/GuruPR.Application/Features/Providers/Exceptions/ProviderValidationException.cs new file mode 100644 index 0000000..6f46762 --- /dev/null +++ b/src/GuruPR.Application/Features/Providers/Exceptions/ProviderValidationException.cs @@ -0,0 +1,14 @@ +using GuruPR.Application.Common.Exceptions; + +namespace GuruPR.Application.Features.Providers.Exceptions; + +public class ProviderValidationException : ValidationExceptionBase +{ + public ProviderValidationException(string message) : base(message) + { + } + + public ProviderValidationException(string message, IReadOnlyDictionary> errors) : base(message, errors) + { + } +} diff --git a/src/GuruPR.Application/Features/Providers/Extensions/ProviderRespositoryExtensions.cs b/src/GuruPR.Application/Features/Providers/Extensions/ProviderRespositoryExtensions.cs new file mode 100644 index 0000000..ee3cb98 --- /dev/null +++ b/src/GuruPR.Application/Features/Providers/Extensions/ProviderRespositoryExtensions.cs @@ -0,0 +1,17 @@ +using GuruPR.Application.Common.Interfaces.Persistence; +using GuruPR.Application.Features.Providers.Exceptions; +using GuruPR.Domain.Entities.Provider; + +namespace GuruPR.Application.Features.Providers.Extensions; + +public static class ProviderRespositoryExtensions +{ + public static async Task GetByIdOrThrowAsync(this IProviderRepository providerRepository, + string id, + CancellationToken cancellationToken = default) + { + var provider = await providerRepository.GetByIdAsync(id, cancellationToken); + + return provider ?? throw new ProviderNotFoundException($"Provider with the given ID {id} not found."); + } +} diff --git a/src/GuruPR.Application/Features/Providers/Profiles/ProviderProfile.cs b/src/GuruPR.Application/Features/Providers/Profiles/ProviderProfile.cs new file mode 100644 index 0000000..2d8fe30 --- /dev/null +++ b/src/GuruPR.Application/Features/Providers/Profiles/ProviderProfile.cs @@ -0,0 +1,24 @@ +using AutoMapper; + +using GuruPR.Application.Features.Agents.Commands.CreateAgent; +using GuruPR.Application.Features.Providers.Commands.UpdateProvider; +using GuruPR.Application.Features.Providers.Dtos; +using GuruPR.Domain.Entities.Provider; +using GuruPR.Domain.Entities.Provider.Operations; + +namespace GuruPR.Application.Features.Providers.Profiles; + +public class ProviderProfile : Profile +{ + public ProviderProfile() + { + CreateMap() + .ForAllMembers(opts => opts.Condition((src, dest, srcMember) => srcMember != null)); + + CreateMap().ReverseMap(); + + CreateMap().ReverseMap(); + + CreateMap().ReverseMap(); + } +} diff --git a/src/GuruPR.Application/Features/Providers/Queries/GetProviderById/GetProviderByIdHandler.cs b/src/GuruPR.Application/Features/Providers/Queries/GetProviderById/GetProviderByIdHandler.cs new file mode 100644 index 0000000..d3d1966 --- /dev/null +++ b/src/GuruPR.Application/Features/Providers/Queries/GetProviderById/GetProviderByIdHandler.cs @@ -0,0 +1,5 @@ +namespace GuruPR.Application.Features.Providers.Queries.GetProviderById; + +public class GetProviderByIdHandler +{ +} diff --git a/src/GuruPR.Application/Features/Providers/Queries/GetProviderById/GetProviderByIdQuery.cs b/src/GuruPR.Application/Features/Providers/Queries/GetProviderById/GetProviderByIdQuery.cs new file mode 100644 index 0000000..b51c238 --- /dev/null +++ b/src/GuruPR.Application/Features/Providers/Queries/GetProviderById/GetProviderByIdQuery.cs @@ -0,0 +1,7 @@ +using GuruPR.Application.Features.Providers.Dtos; + +using MediatR; + +namespace GuruPR.Application.Features.Providers.Queries.GetProviderById; + +public record GetProviderByIdQuery(string ProviderId) : IRequest; diff --git a/src/GuruPR.Application/Features/Providers/Queries/GetProviderById/GetProviderByIdValidator.cs b/src/GuruPR.Application/Features/Providers/Queries/GetProviderById/GetProviderByIdValidator.cs new file mode 100644 index 0000000..81fe7f6 --- /dev/null +++ b/src/GuruPR.Application/Features/Providers/Queries/GetProviderById/GetProviderByIdValidator.cs @@ -0,0 +1,11 @@ +using FluentValidation; + +namespace GuruPR.Application.Features.Providers.Queries.GetProviderById; + +public class GetProviderByIdValidator : AbstractValidator +{ + public GetProviderByIdValidator() + { + + } +} diff --git a/src/GuruPR.Application/Features/Providers/Queries/GetProviders/GetProvidersHandler.cs b/src/GuruPR.Application/Features/Providers/Queries/GetProviders/GetProvidersHandler.cs new file mode 100644 index 0000000..63b2789 --- /dev/null +++ b/src/GuruPR.Application/Features/Providers/Queries/GetProviders/GetProvidersHandler.cs @@ -0,0 +1,36 @@ +using AutoMapper; + +using FluentValidation; + +using GuruPR.Application.Common.Extensions.Validation; +using GuruPR.Application.Common.Interfaces.Persistence; +using GuruPR.Application.Features.Providers.Dtos; +using GuruPR.Application.Features.Providers.Exceptions; + +using MediatR; + +namespace GuruPR.Application.Features.Providers.Queries.GetProviders; + +public class GetProvidersHandler : IRequestHandler> +{ + private readonly IMapper _mapper; + private readonly IUnitOfWork _unitOfWork; + private readonly IValidator _validator; + + public GetProvidersHandler(IMapper mapper, IUnitOfWork unitOfWork, IValidator validator) + { + _mapper = mapper; + _unitOfWork = unitOfWork; + _validator = validator; + } + + public async Task> Handle(GetProvidersQuery request, CancellationToken cancellationToken) + { + await _validator.ThrowIfInvalidAsync(request, + (message, errors) => new ProviderValidationException(message, errors), + cancellationToken); + + var providers = await _unitOfWork.Providers.GetAllAsync(cancellationToken: cancellationToken); + return _mapper.Map>(providers); + } +} diff --git a/src/GuruPR.Application/Features/Providers/Queries/GetProviders/GetProvidersQuery.cs b/src/GuruPR.Application/Features/Providers/Queries/GetProviders/GetProvidersQuery.cs new file mode 100644 index 0000000..e6d9e1a --- /dev/null +++ b/src/GuruPR.Application/Features/Providers/Queries/GetProviders/GetProvidersQuery.cs @@ -0,0 +1,7 @@ +using GuruPR.Application.Features.Providers.Dtos; + +using MediatR; + +namespace GuruPR.Application.Features.Providers.Queries.GetProviders; + +public record GetProvidersQuery : IRequest>; diff --git a/src/GuruPR.Application/Features/Providers/Queries/GetProviders/GetProvidersValidator.cs b/src/GuruPR.Application/Features/Providers/Queries/GetProviders/GetProvidersValidator.cs new file mode 100644 index 0000000..a33c7c8 --- /dev/null +++ b/src/GuruPR.Application/Features/Providers/Queries/GetProviders/GetProvidersValidator.cs @@ -0,0 +1,11 @@ +using FluentValidation; + +namespace GuruPR.Application.Features.Providers.Queries.GetProviders; + +public class GetProvidersValidator : AbstractValidator +{ + public GetProvidersValidator() + { + // No specific validation rules for this query at the moment. + } +} diff --git a/src/GuruPR.Application/Features/Providers/Validators/Extensions/ProviderValidationRules.cs b/src/GuruPR.Application/Features/Providers/Validators/Extensions/ProviderValidationRules.cs new file mode 100644 index 0000000..6bc1221 --- /dev/null +++ b/src/GuruPR.Application/Features/Providers/Validators/Extensions/ProviderValidationRules.cs @@ -0,0 +1,37 @@ +using FluentValidation; + +using GuruPR.Application.Common.Extensions.Validation; +using GuruPR.Domain.Entities.Provider.Enums; + +namespace GuruPR.Application.Features.Providers.Validators.Extensions; + +public static class ProviderValidationRules +{ + public static IRuleBuilderOptions ValidProviderId(this IRuleBuilder ruleBuilder) + { + return ruleBuilder.ValidId("Provider"); + } + + public static IRuleBuilderOptions ValidDisplayName(this IRuleBuilder ruleBuilder) + { + return ruleBuilder.NotEmpty() + .MinimumLength(3) + .WithMessage("Display name must be at least 3 characters long."); + } + + public static IRuleBuilderOptions ValidProviderType(this IRuleBuilder ruleBuilder) + { + return ruleBuilder.NotEmpty() + .WithMessage("Provider type is required."); + } + + public static IRuleBuilderOptions ValidTokenUrl(this IRuleBuilder ruleBuilder) + { + return ruleBuilder.ValidAbsoluteUrl("Token url"); + } + + public static IRuleBuilderOptions ValidAuthorizationUrl(this IRuleBuilder ruleBuilder) + { + return ruleBuilder.ValidAbsoluteUrl("Authorization url"); + } +} diff --git a/src/GuruPR.Application/Features/Users/Dtos/UserDto.cs b/src/GuruPR.Application/Features/Users/Dtos/UserDto.cs new file mode 100644 index 0000000..bcf9151 --- /dev/null +++ b/src/GuruPR.Application/Features/Users/Dtos/UserDto.cs @@ -0,0 +1,16 @@ +namespace GuruPR.Application.Features.Users.Dtos; + +public class UserDto +{ + public Guid Id { get; set; } + + public required string FirstName { get; set; } + + public required string LastName { get; set; } + + public string? Email { get; set; } + + public string? UserName { get; set; } + + public string FullName { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/src/GuruPR.Application/Features/Users/Exceptions/UserAlreadyExistsException.cs b/src/GuruPR.Application/Features/Users/Exceptions/UserAlreadyExistsException.cs new file mode 100644 index 0000000..f986cf1 --- /dev/null +++ b/src/GuruPR.Application/Features/Users/Exceptions/UserAlreadyExistsException.cs @@ -0,0 +1,3 @@ +namespace GuruPR.Application.Features.Users.Exceptions; + +public class UserAlreadyExistsException(string message) : Exception(message); diff --git a/src/GuruPR.Application/Features/Users/Exceptions/UserNotFoundException.cs b/src/GuruPR.Application/Features/Users/Exceptions/UserNotFoundException.cs new file mode 100644 index 0000000..5241093 --- /dev/null +++ b/src/GuruPR.Application/Features/Users/Exceptions/UserNotFoundException.cs @@ -0,0 +1,5 @@ +using GuruPR.Application.Common.Exceptions; + +namespace GuruPR.Application.Features.Users.Exceptions; + +public class UserNotFoundException(string message) : NotFoundException(message); diff --git a/src/GuruPR.Application/Features/Users/Exceptions/UserRoleOperationFailedException.cs b/src/GuruPR.Application/Features/Users/Exceptions/UserRoleOperationFailedException.cs new file mode 100644 index 0000000..101ccaa --- /dev/null +++ b/src/GuruPR.Application/Features/Users/Exceptions/UserRoleOperationFailedException.cs @@ -0,0 +1,5 @@ +using GuruPR.Application.Common.Exceptions; + +namespace GuruPR.Application.Features.Users.Exceptions; + +public class UserRoleOperationFailedException(string message) : OperationFailedException(message); diff --git a/src/GuruPR.Application/Features/Users/Extensions/UserRepositoryExtensions.cs b/src/GuruPR.Application/Features/Users/Extensions/UserRepositoryExtensions.cs new file mode 100644 index 0000000..878d97c --- /dev/null +++ b/src/GuruPR.Application/Features/Users/Extensions/UserRepositoryExtensions.cs @@ -0,0 +1,18 @@ +using GuruPR.Application.Features.Users.Exceptions; +using GuruPR.Domain.Entities; + +using Microsoft.AspNetCore.Identity; + +namespace GuruPR.Application.Features.Users.Extensions; + +public static class UserRepositoryExtensions +{ + public static async Task GetByIdOrThrowAsync(this UserManager userManager, + string id, + CancellationToken cancellationToken = default) + { + var user = await userManager.FindByIdAsync(id); + + return user ?? throw new UserNotFoundException($"User with the given ID {id} not found."); + } +} diff --git a/src/GuruPR.Application/Features/Users/Profiles/UserProfile.cs b/src/GuruPR.Application/Features/Users/Profiles/UserProfile.cs new file mode 100644 index 0000000..8e5ae90 --- /dev/null +++ b/src/GuruPR.Application/Features/Users/Profiles/UserProfile.cs @@ -0,0 +1,15 @@ +using AutoMapper; + +using GuruPR.Application.Features.Users.Dtos; +using GuruPR.Domain.Entities; + +namespace GuruPR.Application.Features.Users.Profiles; + +public class UserProfile : Profile +{ + public UserProfile() + { + CreateMap() + .ForMember(dest => dest.FullName, opt => opt.MapFrom(src => src.ToString())); + } +} diff --git a/src/GuruPR.Application/Features/Users/Queries/GetCurrentUser/GetCurrentUserHandler.cs b/src/GuruPR.Application/Features/Users/Queries/GetCurrentUser/GetCurrentUserHandler.cs new file mode 100644 index 0000000..bdd7ea5 --- /dev/null +++ b/src/GuruPR.Application/Features/Users/Queries/GetCurrentUser/GetCurrentUserHandler.cs @@ -0,0 +1,42 @@ +using AutoMapper; + +using GuruPR.Application.Common.Interfaces.Presentation; +using GuruPR.Application.Features.Users.Dtos; +using GuruPR.Application.Features.Users.Extensions; +using GuruPR.Domain.Entities; + +using MediatR; + +using Microsoft.AspNetCore.Identity; + +namespace GuruPR.Application.Features.Users.Queries.GetCurrentUser; + +public class GetCurrentUserHandler : IRequestHandler +{ + private readonly IMapper _mapper; + private readonly ICurrentUserService _currentUserService; + private readonly UserManager _userManager; + + public GetCurrentUserHandler(IMapper mapper, + ICurrentUserService currentUserService, + UserManager userManager) + { + _mapper = mapper; + _currentUserService = currentUserService; + _userManager = userManager; + } + + public async Task Handle(GetCurrentUserQuery request, CancellationToken cancellationToken) + { + var currentUserId = _currentUserService.UserId; + + if (string.IsNullOrEmpty(currentUserId)) + { + throw new UnauthorizedAccessException("User is not authenticated."); + } + + var user = await _userManager.GetByIdOrThrowAsync(currentUserId, cancellationToken); + + return _mapper.Map(user); + } +} diff --git a/src/GuruPR.Application/Features/Users/Queries/GetCurrentUser/GetCurrentUserQuery.cs b/src/GuruPR.Application/Features/Users/Queries/GetCurrentUser/GetCurrentUserQuery.cs new file mode 100644 index 0000000..8eebb7a --- /dev/null +++ b/src/GuruPR.Application/Features/Users/Queries/GetCurrentUser/GetCurrentUserQuery.cs @@ -0,0 +1,11 @@ +using GuruPR.Application.Common.Markers.Interfaces; +using GuruPR.Application.Features.Users.Dtos; + +using MediatR; + +namespace GuruPR.Application.Features.Users.Queries.GetCurrentUser; + +public record GetCurrentUserQuery : IRequest, IUserContextCommand +{ + public string UserId { get; set; } = null!; +} diff --git a/src/GuruPR.Application/Features/Users/Services/UserRoleService.cs b/src/GuruPR.Application/Features/Users/Services/UserRoleService.cs new file mode 100644 index 0000000..7cf6ed4 --- /dev/null +++ b/src/GuruPR.Application/Features/Users/Services/UserRoleService.cs @@ -0,0 +1,42 @@ +using GuruPR.Application.Common.Interfaces.Application; +using GuruPR.Application.Exceptions.Account; +using GuruPR.Application.Features.Users.Extensions; +using GuruPR.Domain.Entities; +using GuruPR.Domain.Enums; +using GuruPR.Domain.Extensions.User; + +using Microsoft.AspNetCore.Identity; + +namespace GuruPR.Application.Features.Users.Services; + +public class UserRoleService : IUserRoleService +{ + private readonly UserManager _userManager; + + public UserRoleService(UserManager userManager) + { + _userManager = userManager; + } + + public async Task AssignRoleAsync(string userId, UserRole userRole) + { + var user = await _userManager.GetByIdOrThrowAsync(userId); + + var result = await _userManager.AddToRoleAsync(user, userRole.ToName()); + if (!result.Succeeded) + { + throw new UserRoleOperationFailedException($"Failed to add role {userRole.ToName()} to user with email {user.Email}"); + } + } + + public async Task RemoveRoleAsync(string userId, UserRole userRole) + { + var user = await _userManager.GetByIdOrThrowAsync(userId); + + var result = await _userManager.RemoveFromRoleAsync(user, userRole.ToName()); + if (!result.Succeeded) + { + throw new UserRoleOperationFailedException($"Failed to remove role {userRole.ToName()} from user with email {user.Email}"); + } + } +} diff --git a/src/GuruPR.Application/GuruPR.Application.csproj b/src/GuruPR.Application/GuruPR.Application.csproj index 3fcadcb..1dace2f 100644 --- a/src/GuruPR.Application/GuruPR.Application.csproj +++ b/src/GuruPR.Application/GuruPR.Application.csproj @@ -12,9 +12,16 @@ + + + + + + + diff --git a/src/GuruPR.Application/Interfaces/Application/IAgentService.cs b/src/GuruPR.Application/Interfaces/Application/IAgentService.cs deleted file mode 100644 index eb2e4e0..0000000 --- a/src/GuruPR.Application/Interfaces/Application/IAgentService.cs +++ /dev/null @@ -1,17 +0,0 @@ -using GuruPR.Application.Dtos.Agent; -using GuruPR.Domain.Entities; - -namespace GuruPR.Application.Interfaces.Application; - -public interface IAgentService -{ - Task> GetAllAgentsAsync(string? userId = null); - - Task GetAgentByIdAsync(string agentId); - - Task CreateAgentAsync(CreateAgentRequest createAgentRequest, string userId); - - Task UpdateAgentAsync(string agentId, UpdateAgentRequest updateAgentRequest); - - Task DeleteAgentAsync(string agentId); -} diff --git a/src/GuruPR.Application/Interfaces/Application/IConversationService.cs b/src/GuruPR.Application/Interfaces/Application/IConversationService.cs deleted file mode 100644 index 02a4345..0000000 --- a/src/GuruPR.Application/Interfaces/Application/IConversationService.cs +++ /dev/null @@ -1,20 +0,0 @@ -using GuruPR.Application.Dtos.Conversation; -using GuruPR.Domain.Entities.Conversation; -using GuruPR.Domain.Requests; - -namespace GuruPR.Application.Interfaces.Application; - -public interface IConversationService -{ - Task> GetAllConversationsByUserIdAsync(string userId); - - Task GetConversationByIdAsync(string conversationId, string userId); - - Task CreateConversationAsync(CreateConversationRequest createConversationRequest, string userId); - - Task UpdateConversationAsync(string conversationId, string userId, UpdateConversationRequest updateConversationRequest); - - Task DeleteConversationAsync(string conversationId); - - Task RunAgentWorkflowAsync(AgentExecutionRequest request, string userId); -} diff --git a/src/GuruPR.Application/Interfaces/Application/IMessageService.cs b/src/GuruPR.Application/Interfaces/Application/IMessageService.cs deleted file mode 100644 index 78067c9..0000000 --- a/src/GuruPR.Application/Interfaces/Application/IMessageService.cs +++ /dev/null @@ -1,8 +0,0 @@ -using GuruPR.Domain.Entities.Message; - -namespace GuruPR.Application.Interfaces.Application; - -public interface IMessageService -{ - Task> GetMessagesByConversationIdAsync(string conversationId, string userId); -} diff --git a/src/GuruPR.Application/Interfaces/Application/IProviderConnectionService.cs b/src/GuruPR.Application/Interfaces/Application/IProviderConnectionService.cs deleted file mode 100644 index 5286c45..0000000 --- a/src/GuruPR.Application/Interfaces/Application/IProviderConnectionService.cs +++ /dev/null @@ -1,22 +0,0 @@ -using GuruPR.Application.Dtos.OAuth.ProviderConnection; -using GuruPR.Domain.Entities.Enums; -using GuruPR.Domain.Entities.OAuth; - -namespace GuruPR.Application.Interfaces.Application; - -public interface IProviderConnectionService -{ - Task> GetConnectionsByProviderIdAsync(string providerId); - - Task GetProviderConnectionByIdAsync(string providerId, string providerConnectionId); - - Task GetProviderConnectionByScopeAndProviderNameAsync(string providerName, string scope); - - Task GetProviderConnectionByProviderTypeAndScopeAsync(OAuthProviderType OAuthProviderType, string scope); - - Task AddProviderConnectionToProviderAsync(string providerId, CreateProviderConnectionRequest createProviderConnectionRequest); - - Task UpdateProviderConnectionByProviderTypeAsync(OAuthProviderType OAuthProviderType, string providerConnectionId, UpdateProviderConnectionRequest updateProviderConnectionRequest); - - Task DeleteProviderConnectionAsync(string providerId, string providerConnectionId); -} diff --git a/src/GuruPR.Application/Interfaces/Application/IProviderService.cs b/src/GuruPR.Application/Interfaces/Application/IProviderService.cs deleted file mode 100644 index 71dbcc1..0000000 --- a/src/GuruPR.Application/Interfaces/Application/IProviderService.cs +++ /dev/null @@ -1,22 +0,0 @@ -using GuruPR.Application.Dtos.OAuth.Provider; -using GuruPR.Domain.Entities.Enums; -using GuruPR.Domain.Entities.OAuth; - -namespace GuruPR.Application.Interfaces.Application; - -public interface IProviderService -{ - Task CreateProviderAsync(CreateProviderRequest createProviderRequest); - - Task> GetAllProvidersAsync(); - - Task GetProviderByIdAsync(string providerId); - - Task GetProviderByNameAsync(string providerName); - - Task GetProviderByTypeAsync(OAuthProviderType oAuthProviderType); - - Task UpdateProviderAsync(string providerId, UpdateProviderRequest updateProviderRequest); - - Task DeleteProviderAsync(string providerId); -} diff --git a/src/GuruPR.Application/Interfaces/Application/IToolService.cs b/src/GuruPR.Application/Interfaces/Application/IToolService.cs deleted file mode 100644 index 3b2781d..0000000 --- a/src/GuruPR.Application/Interfaces/Application/IToolService.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace GuruPR.Application.Interfaces.Application; - -public interface IToolService -{ -} diff --git a/src/GuruPR.Application/Interfaces/Infrastructure/IAIChatProvider.cs b/src/GuruPR.Application/Interfaces/Infrastructure/IAIChatProvider.cs deleted file mode 100644 index 39ee286..0000000 --- a/src/GuruPR.Application/Interfaces/Infrastructure/IAIChatProvider.cs +++ /dev/null @@ -1,13 +0,0 @@ -using GuruPR.Application.Interfaces.Infrastructure.SemanticKernel.Models; -using GuruPR.Domain.Entities; -using GuruPR.Domain.Entities.Conversation; -using GuruPR.Domain.Entities.Message; - -namespace GuruPR.Application.Interfaces.Infrastructure; - -public interface IAIChatProvider -{ - Task ExecuteAsync(Agent agent, Conversation conversation, IList messages, string userMessage); - - Task GenerateSummaryAsync(IList messages, string? existingSummary); -} diff --git a/src/GuruPR.Application/Interfaces/Infrastructure/IExternalAuthService.cs b/src/GuruPR.Application/Interfaces/Infrastructure/IExternalAuthService.cs deleted file mode 100644 index 8a5b245..0000000 --- a/src/GuruPR.Application/Interfaces/Infrastructure/IExternalAuthService.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; - -namespace GuruPR.Application.Interfaces.Infrastructure; - -public interface IExternalAuthService -{ - Task InitiateGoogleLoginAsync(string? returnUrl, HttpContext httpContext); - - Task HandleGoogleCallbackAsync(string returnUrl, HttpContext httpContext); -} diff --git a/src/GuruPR.Application/Interfaces/Infrastructure/ITokenService.cs b/src/GuruPR.Application/Interfaces/Infrastructure/ITokenService.cs deleted file mode 100644 index f909c68..0000000 --- a/src/GuruPR.Application/Interfaces/Infrastructure/ITokenService.cs +++ /dev/null @@ -1,13 +0,0 @@ -using GuruPR.Application.Dtos.Jwt; -using GuruPR.Domain.Entities; - -namespace GuruPR.Application.Interfaces.Infrastructure; - -public interface ITokenService -{ - Task GenerateTokenAsync(User user); - - string GenerateRefreshToken(); - - void WriteAuthTokenAsHttpOnlyCookie(string cookieName, string token, DateTime expiration); -} diff --git a/src/GuruPR.Application/Interfaces/Infrastructure/SemanticKernel/Plugins/IAgentTool.cs b/src/GuruPR.Application/Interfaces/Infrastructure/SemanticKernel/Plugins/IAgentTool.cs deleted file mode 100644 index e9390d2..0000000 --- a/src/GuruPR.Application/Interfaces/Infrastructure/SemanticKernel/Plugins/IAgentTool.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace GuruPR.Application.Interfaces.Infrastructure.SemanticKernel.Plugins; - -public interface IAgentTool -{ - string Name { get; } -} diff --git a/src/GuruPR.Application/Interfaces/Persistence/IGenericRepository.cs b/src/GuruPR.Application/Interfaces/Persistence/IGenericRepository.cs deleted file mode 100644 index 0ea65c4..0000000 --- a/src/GuruPR.Application/Interfaces/Persistence/IGenericRepository.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace GuruPR.Application.Interfaces.Persistence; - -public interface IGenericRepository where T : class -{ - Task GetByIdAsync(string id); - - Task> GetAllAsync(); - - Task AddAsync(T entity); - - void Update(T entity); - - void Delete(T entity); -} diff --git a/src/GuruPR.Application/Interfaces/Persistence/IMessageRepository.cs b/src/GuruPR.Application/Interfaces/Persistence/IMessageRepository.cs deleted file mode 100644 index 636317e..0000000 --- a/src/GuruPR.Application/Interfaces/Persistence/IMessageRepository.cs +++ /dev/null @@ -1,10 +0,0 @@ -using GuruPR.Domain.Entities.Message; - -namespace GuruPR.Application.Interfaces.Persistence; - -public interface IMessageRepository : IGenericRepository -{ - Task> GetMessagesAsync(string conversationId, int? lastMessages = null); - - Task DeleteConversationMessagesAsync(string conversationId); -} diff --git a/src/GuruPR.Application/Profiles/Agents/AgentProfile.cs b/src/GuruPR.Application/Profiles/Agents/AgentProfile.cs deleted file mode 100644 index 02830be..0000000 --- a/src/GuruPR.Application/Profiles/Agents/AgentProfile.cs +++ /dev/null @@ -1,23 +0,0 @@ -using AutoMapper; - -using GuruPR.Application.Dtos.Agent; -using GuruPR.Domain.Entities; - -namespace GuruPR.Application.Profiles.Agents; - -public class AgentProfile : Profile -{ - public AgentProfile() - { - CreateMap(); - CreateMap(); - - CreateMap(); - CreateMap(); - - CreateMap() - .ForAllMembers(opts => opts.Condition((src, dest, srcMember) => srcMember != null)); - CreateMap() - .ForAllMembers(opts => opts.Condition((src, dest, srcMember) => srcMember != null)); - } -} diff --git a/src/GuruPR.Application/Profiles/Conversations/ConversationProfile.cs b/src/GuruPR.Application/Profiles/Conversations/ConversationProfile.cs deleted file mode 100644 index cc157ab..0000000 --- a/src/GuruPR.Application/Profiles/Conversations/ConversationProfile.cs +++ /dev/null @@ -1,23 +0,0 @@ -using AutoMapper; - -using GuruPR.Application.Dtos.Conversation; -using GuruPR.Domain.Entities.Conversation; - -namespace GuruPR.Application.Profiles.Conversations; - -public class ConversationProfile : Profile -{ - public ConversationProfile() - { - CreateMap(); - CreateMap(); - - CreateMap(); - CreateMap(); - - CreateMap() - .ForAllMembers(opts => opts.Condition((src, dest, srcMember) => srcMember != null)); - CreateMap() - .ForAllMembers(opts => opts.Condition((src, dest, srcMember) => srcMember != null)); - } -} diff --git a/src/GuruPR.Application/Profiles/OAuth/ProviderConnectionProfile.cs b/src/GuruPR.Application/Profiles/OAuth/ProviderConnectionProfile.cs deleted file mode 100644 index 4430233..0000000 --- a/src/GuruPR.Application/Profiles/OAuth/ProviderConnectionProfile.cs +++ /dev/null @@ -1,23 +0,0 @@ -using AutoMapper; - -using GuruPR.Application.Dtos.OAuth.ProviderConnection; -using GuruPR.Domain.Entities.OAuth; - -namespace GuruPR.Application.Profiles.OAuth; - -public class ProviderConnectionProfile : Profile -{ - public ProviderConnectionProfile() - { - CreateMap(); - CreateMap(); - - CreateMap(); - CreateMap(); - - CreateMap() - .ForAllMembers(opts => opts.Condition((src, dest, srcMember) => srcMember != null)); - CreateMap() - .ForAllMembers(opts => opts.Condition((src, dest, srcMember) => srcMember != null)); - } -} diff --git a/src/GuruPR.Application/Profiles/OAuth/ProviderProfile.cs b/src/GuruPR.Application/Profiles/OAuth/ProviderProfile.cs deleted file mode 100644 index 77b1690..0000000 --- a/src/GuruPR.Application/Profiles/OAuth/ProviderProfile.cs +++ /dev/null @@ -1,27 +0,0 @@ -using AutoMapper; - -using GuruPR.Application.Dtos.OAuth.Provider; -using GuruPR.Domain.Entities.OAuth; - -namespace GuruPR.Application.Profiles.OAuth; - -public class ProviderProfile : Profile -{ - public ProviderProfile() - { - CreateMap() - .ForAllMembers(opts => opts.Condition((src, dest, srcMember) => srcMember != null)); - - CreateMap(); - CreateMap(); - - CreateMap(); - CreateMap() - .ForMember(dest => dest.ProviderConnections, opt => opt.MapFrom(src => src.ProviderConnections)); - - CreateMap() - .ForAllMembers(opts => opts.Condition((src, dest, srcMember) => srcMember != null)); - CreateMap() - .ForAllMembers(opts => opts.Condition((src, dest, srcMember) => srcMember != null)); - } -} diff --git a/src/GuruPR.Application/Services/Account/AccountService.cs b/src/GuruPR.Application/Services/Account/AccountService.cs deleted file mode 100644 index a1f4083..0000000 --- a/src/GuruPR.Application/Services/Account/AccountService.cs +++ /dev/null @@ -1,405 +0,0 @@ -using System.Security.Claims; - -using GuruPR.Application.Exceptions; -using GuruPR.Application.Exceptions.Account; -using GuruPR.Application.Interfaces.Application; -using GuruPR.Application.Interfaces.Infrastructure; -using GuruPR.Application.Interfaces.Persistence; -using GuruPR.Application.Settings.FrontEnd; -using GuruPR.Domain.Entities; -using GuruPR.Domain.Enums; -using GuruPR.Domain.Extensions.User; -using GuruPR.Domain.Requests; - -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Identity; -using Microsoft.AspNetCore.Identity.UI.Services; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace GuruPR.Application.Services.Account; - -public class AccountService : IAccountService -{ - private readonly ILogger _logger; - private readonly ITokenService _tokenService; - private readonly IHasher _hasher; - private readonly IUnitOfWork _unitOfWork; - private readonly IEmailSender _emailSender; - private readonly IHttpContextAccessor _httpContextAccessor; - private readonly IEmailTemplateService _emailTemplateService; - private readonly IAccountLinkGenerator _accountLinkGenerator; - private readonly FrontEndSettings _frontEndSettings; - private readonly UserManager _userManager; - - private const int RefreshTokenExpirationDays = 7; - - public AccountService(ILogger logger, - ITokenService tokenService, - IHasher hasher, - IUnitOfWork unitOfWork, - IEmailSender emailSender, - IEmailTemplateService emailTemplateService, - IAccountLinkGenerator accountLinkGenerator, - IHttpContextAccessor httpContextAccessor, - IOptions frontEndSettings, - UserManager userManager) - { - _logger = logger; - _tokenService = tokenService; - _hasher = hasher; - _unitOfWork = unitOfWork; - _emailSender = emailSender; - _emailTemplateService = emailTemplateService; - _accountLinkGenerator = accountLinkGenerator; - _httpContextAccessor = httpContextAccessor; - _frontEndSettings = frontEndSettings.Value; - _userManager = userManager; - } - - #region Public Methods - - public async Task RegisterAsync(RegisterRequest registerRequest) - { - ArgumentNullException.ThrowIfNull(registerRequest, nameof(registerRequest)); - - await EnsureUserDoesNotExistAsync(registerRequest.Email); - - await _unitOfWork.BeginUserManagementTransactionAsync(); - - User? user = null; - - try - { - user = await CreateUserAsync(registerRequest); - - await AssignRoleAsync(user.Id.ToString(), UserRole.User); - - await _unitOfWork.CommitUserManagementTransactionAsync(); - } - catch (Exception) - { - await _unitOfWork.RollbackUserManagementTransactionAsync(); - - throw; - } - - await SendConfirmationEmailAsync(user); - } - - public async Task LoginAsync(LoginRequest loginRequest) - { - ArgumentNullException.ThrowIfNull(loginRequest, nameof(loginRequest)); - - var user = await FindUserByEmailAsync(loginRequest.Email); - - if (user == null) - { - throw new UserNotFoundException($"User with the specified email {loginRequest.Email} was not found."); - } - - if (!await _userManager.CheckPasswordAsync(user, loginRequest.Password)) - { - throw new LoginFailedException("Login failed. Invalid email or password."); - } - - await SetAuthenticationTokensAsync(user); - } - - public async Task RefreshTokenAsync(string userId, string refreshToken) - { - if (string.IsNullOrWhiteSpace(refreshToken)) - { - throw new RefreshTokenException("Refresh token is missing."); - } - - var user = await _userManager.FindByIdAsync(userId); - if (user == null) - { - throw new RefreshTokenException($"User with the specified ID {userId} was not found."); - } - - var isValidRefreshTokenHash = _hasher.Verify(user.RefreshTokenHash, refreshToken); - if (!isValidRefreshTokenHash || user.RefreshTokenExpiryTime <= DateTime.UtcNow) - { - throw new RefreshTokenException("Refresh token has expired."); - } - - await SetAuthenticationTokensAsync(user); - } - - public async Task ConfirmEmailAsync(string userId, string token) - { - var user = await GetUserByIdOrThrowException(userId); - - var result = await _userManager.ConfirmEmailAsync(user, token); - if (!result.Succeeded) - { - throw new EmailConfirmationException("Email confirmation failed."); - } - } - - public Task GetEmailConfirmationRedirectUrlAsync(bool success) - { - var path = success ? _frontEndSettings.EmailConfirmationPath - : _frontEndSettings.EmailConfirmationFailedPath; - - var url = _frontEndSettings.BaseUrl + path; - - return Task.FromResult(url); - } - - public async Task LogoutAsync(string userId, string refreshToken) - { - var user = await GetUserByIdOrThrowException(userId); - var isValidRefreshToken = _hasher.Verify(user.RefreshTokenHash, refreshToken); - if (!isValidRefreshToken || user.RefreshTokenExpiryTime < DateTime.UtcNow) - { - throw new RefreshTokenException("Invalid refresh token or user ID."); - } - - user.RefreshTokenHash = null; - - var updateResult = await _userManager.UpdateAsync(user); - if (!updateResult.Succeeded) - { - throw new LogoutException($"Failed to logout user with email {user.Email}"); - } - - _httpContextAccessor.HttpContext?.Response.Cookies.Delete("AccessToken"); - _httpContextAccessor.HttpContext?.Response.Cookies.Delete("RefreshToken"); - } - - public async Task AssignRoleAsync(string userId, UserRole userRole) - { - var user = await GetUserByIdOrThrowException(userId); - - var result = await _userManager.AddToRoleAsync(user, userRole.ToName()); - if (!result.Succeeded) - { - throw new UserRoleOperationFailedException($"Failed to add role {userRole.ToName()} to user with email {user.Email}"); - } - } - - public async Task RemoveRoleAsync(string userId, UserRole userRole) - { - var user = await GetUserByIdOrThrowException(userId); - - var result = await _userManager.RemoveFromRoleAsync(user, userRole.ToName()); - if (!result.Succeeded) - { - throw new UserRoleOperationFailedException($"Failed to remove role {userRole.ToName()} from user with email {user.Email}"); - } - } - - public async Task LoginWithExternalProviderAsync(ClaimsPrincipal? claimsPrincipal, string provider) - { - if (claimsPrincipal == null) - { - throw new ExternalLoginProviderException(provider, "Claims principal is missing"); - } - - var email = ExtractEmailFromClaims(claimsPrincipal, provider); - var user = await _userManager.FindByEmailAsync(email); - - await _unitOfWork.BeginUserManagementTransactionAsync(); - - try - { - if (user == null) - { - user = await CreateUserFromExternalProviderClaimsAsync(claimsPrincipal, provider, email); - } - - await SetAuthenticationTokensAsync(user); - - await _unitOfWork.CommitUserManagementTransactionAsync(); - } - catch (Exception) - { - await _unitOfWork.RollbackUserManagementTransactionAsync(); - - throw; - } - } - - #endregion - - #region Private Methods - - private async Task GetUserByIdOrThrowException(string userId) - { - var user = await _userManager.FindByIdAsync(userId); - if (user == null) - { - throw new UserNotFoundException($"User with the specified ID {userId} was not found."); - } - - return user; - } - - private void ThrowRegistrationException(IEnumerable errors) - { - var errorGroups = errors.GroupBy(error => GetErrorCategory(error.Code)) - .ToDictionary( - group => group.Key, - group => group.Select(error => error.Description) - .ToList() - ); - - throw new RegistrationFailedException($"Registration failed.", errorGroups); - } - - private string GetErrorCategory(string errorCode) - { - if (errorCode.StartsWith("Password", StringComparison.OrdinalIgnoreCase)) - { - return "Password"; - } - - if (errorCode.Contains("Email", StringComparison.OrdinalIgnoreCase)) - { - return "Email"; - } - - if (errorCode.StartsWith("FirstName", StringComparison.OrdinalIgnoreCase)) - { - return "FirstName"; - } - - if (errorCode.StartsWith("LastName", StringComparison.OrdinalIgnoreCase)) - { - return "LastName"; - } - - return "Other"; - } - - private async Task EnsureUserDoesNotExistAsync(string email) - { - var user = await _userManager.FindByEmailAsync(email); - - if (user != null) - { - throw new UserAlreadyExistsException($"User with email '{email}' already exists."); - } - - return null; - } - - private async Task FindUserByEmailAsync(string email) - { - var user = await _userManager.FindByEmailAsync(email); - - return user ?? throw new LoginFailedException("Invalid email or password."); - } - - private async Task SetAuthenticationTokensAsync(User user) - { - var jwtTokenResult = await _tokenService.GenerateTokenAsync(user); - var newRefreshToken = _tokenService.GenerateRefreshToken(); - var newRefreshTokenHash = _hasher.Hash(newRefreshToken); - var refreshTokenExpiry = DateTime.UtcNow.AddDays(RefreshTokenExpirationDays); - - user.RefreshTokenHash = newRefreshTokenHash; - user.RefreshTokenExpiryTime = refreshTokenExpiry; - - var updateResult = await _userManager.UpdateAsync(user); - if (!updateResult.Succeeded) - { - throw new OperationFailedException($"Failed to update tokens for user with email {user.Email}."); - } - - _tokenService.WriteAuthTokenAsHttpOnlyCookie("AccessToken", jwtTokenResult.Token, jwtTokenResult.ExpiresAtUtc); - _tokenService.WriteAuthTokenAsHttpOnlyCookie("RefreshToken", newRefreshToken, refreshTokenExpiry); - } - - private async Task CreateUserAsync(RegisterRequest registerRequest) - { - var user = new User - { - FirstName = registerRequest.FirstName, - LastName = registerRequest.LastName, - Email = registerRequest.Email, - UserName = registerRequest.Email - }; - - var result = await _userManager.CreateAsync(user, registerRequest.Password); - if (!result.Succeeded) - { - ThrowRegistrationException(result.Errors); - } - - return user; - } - - private async Task SendConfirmationEmailAsync(User user) - { - var token = await _userManager.GenerateEmailConfirmationTokenAsync(user); - var confirmationLink = _accountLinkGenerator.GenerateConfirmationLink(user.Id, token); - var emailBody = _emailTemplateService.BuildConfirmationEmailBody(user.FirstName, confirmationLink); - - await _emailSender.SendEmailAsync(user.Email ?? throw new OperationFailedException("Failed to send confirmation email."), - "Confirm Your GuruPR Account ✨", - emailBody); - } - - private static string ExtractEmailFromClaims(ClaimsPrincipal claimsPrincipal, string provider) - { - var email = claimsPrincipal.FindFirstValue(ClaimTypes.Email); - if (string.IsNullOrWhiteSpace(email)) - { - throw new ExternalLoginProviderException(provider, "Email claim is missing"); - } - - return email; - } - - private async Task CreateUserFromExternalProviderClaimsAsync(ClaimsPrincipal claimsPrincipal, string provider, string email) - { - var user = new User - { - Email = email, - UserName = email, - FirstName = claimsPrincipal.FindFirstValue(ClaimTypes.GivenName) ?? provider.Normalize(), - LastName = claimsPrincipal.FindFirstValue(ClaimTypes.Surname) ?? "User", - EmailConfirmed = true - }; - - var createResult = await _userManager.CreateAsync(user); - if (!createResult.Succeeded) - { - var errors = string.Join(", ", createResult.Errors.Select(e => e.Description)); - - _logger.LogError("Error creating user from external provider {Provider}: {Errors}", provider, errors); - - throw new RegistrationFailedException($"Failed to create a user account using the external provider {provider}."); - } - - await AssignRoleAsync(user.Id.ToString(), UserRole.User); - await AddLoginInfoAsync(user, provider, claimsPrincipal); - - return user; - } - - private async Task AddLoginInfoAsync(User user, string provider, ClaimsPrincipal claimsPrincipal) - { - var userNameIdentifier = claimsPrincipal.FindFirstValue(ClaimTypes.NameIdentifier); - - if (string.IsNullOrWhiteSpace(userNameIdentifier)) - { - throw new ExternalLoginProviderException(provider, $"{provider} user ID claim is missing"); - } - - var loginInfo = new UserLoginInfo(provider, userNameIdentifier, provider); - var loginResult = await _userManager.AddLoginAsync(user, loginInfo); - - if (!loginResult.Succeeded) - { - var errors = string.Join(", ", loginResult.Errors.Select(e => e.Description)); - throw new ExternalLoginProviderException(provider, $"Unable to link {provider} login: {errors}"); - } - } - - #endregion -} \ No newline at end of file diff --git a/src/GuruPR.Application/Services/AgentService.cs b/src/GuruPR.Application/Services/AgentService.cs deleted file mode 100644 index 5fd0fd2..0000000 --- a/src/GuruPR.Application/Services/AgentService.cs +++ /dev/null @@ -1,133 +0,0 @@ -using AutoMapper; - -using GuruPR.Application.Dtos.Agent; -using GuruPR.Application.Exceptions; -using GuruPR.Application.Exceptions.Account; -using GuruPR.Application.Exceptions.Agent; -using GuruPR.Application.Interfaces.Application; -using GuruPR.Application.Interfaces.Persistence; -using GuruPR.Domain.Entities; -using GuruPR.Domain.Errors; - -using Microsoft.AspNetCore.Identity; -using Microsoft.Extensions.Logging; - -namespace GuruPR.Application.Services; - -public class AgentService : IAgentService -{ - private readonly ILogger _logger; - private readonly IUnitOfWork _unitOfWork; - private readonly IMapper _mapper; - private readonly UserManager _userManager; - - public AgentService(ILogger logger, - IUnitOfWork unitOfWork, - IMapper mapper, - UserManager userManager) - { - _logger = logger; - _unitOfWork = unitOfWork; - _mapper = mapper; - _userManager = userManager; - } - - #region Public Methods - - public async Task> GetAllAgentsAsync(string? userId = null) - { - if (string.IsNullOrEmpty(userId)) - { - return await _unitOfWork.Agents.GetAllAsync(); - } - - return await _unitOfWork.Agents.GetAllAgentsAsync(userId); - } - - public async Task GetAgentByIdAsync(string agentId) - { - var agent = await _unitOfWork.Agents.GetByIdAsync(agentId); - if (agent == null) - { - throw new NotFoundException($"Agent with ID {agentId} not found."); - } - - return agent; - } - - public async Task CreateAgentAsync(CreateAgentRequest createAgentRequest, string userId) - { - var user = await GetUserByIdOrThrowException(userId); - var agent = _mapper.Map(createAgentRequest); - - agent.CreatedByUserId = user.Id.ToString(); - - ValidateAgent(agent); - - var createdAgent = await _unitOfWork.Agents.AddAsync(agent); - await _unitOfWork.SaveGuruChangesAsync(); - - return createdAgent; - } - - public async Task UpdateAgentAsync(string agentId, UpdateAgentRequest updateAgentRequest) - { - var agent = await GetAgentByIdAsync(agentId); - var updatedAgent = _mapper.Map(updateAgentRequest, agent); - - ValidateAgent(updatedAgent); - - _unitOfWork.Agents.Update(updatedAgent); - await _unitOfWork.SaveGuruChangesAsync(); - - return updatedAgent; - } - - public async Task DeleteAgentAsync(string agentId) - { - var agent = await GetAgentByIdAsync(agentId); - - _unitOfWork.Agents.Delete(agent); - - var result = await _unitOfWork.SaveGuruChangesAsync(); - return result > 0; - } - - #endregion - - #region Private Methods - - private void ValidateAgent(Agent agent) - { - var validationErrors = agent.Validate().ToList(); - if (validationErrors.Any()) - { - throw CreateValidationException(validationErrors); - } - } - - private AgentValidationException CreateValidationException(IEnumerable errors) - { - var errorGroups = errors.GroupBy(error => error.Field) - .ToDictionary( - group => group.Key, - group => group.Select(error => error.Message) - .ToList() - ); - - return new AgentValidationException($"Agent validation failed.", errorGroups); - } - - private async Task GetUserByIdOrThrowException(string userId) - { - var user = await _userManager.FindByIdAsync(userId); - if (user == null) - { - throw new UserNotFoundException($"User with the specified ID {userId} was not found."); - } - - return user; - } - - #endregion -} diff --git a/src/GuruPR.Application/Services/ConversationService.cs b/src/GuruPR.Application/Services/ConversationService.cs deleted file mode 100644 index 15e4c95..0000000 --- a/src/GuruPR.Application/Services/ConversationService.cs +++ /dev/null @@ -1,224 +0,0 @@ -using AutoMapper; - -using GuruPR.Application.Dtos.Conversation; -using GuruPR.Application.Exceptions; -using GuruPR.Application.Interfaces.Application; -using GuruPR.Application.Interfaces.Infrastructure; -using GuruPR.Application.Interfaces.Infrastructure.SemanticKernel.Models; -using GuruPR.Application.Interfaces.Persistence; -using GuruPR.Domain.Entities; -using GuruPR.Domain.Entities.Configurations.Enums; -using GuruPR.Domain.Entities.Conversation; -using GuruPR.Domain.Entities.Message; -using GuruPR.Domain.Requests; - -using Microsoft.Extensions.Logging; - -namespace GuruPR.Application.Services; - -public class ConversationService : IConversationService -{ - private readonly ILogger _logger; - private readonly IAIChatProvider _aiChatProvider; - private readonly IUnitOfWork _unitOfWork; - private readonly IMapper _mapper; - - public ConversationService(ILogger logger, - IAIChatProvider aiChatProvider, - IUnitOfWork unitOfWork, - IMapper mapper) - { - _logger = logger; - _aiChatProvider = aiChatProvider; - _unitOfWork = unitOfWork; - _mapper = mapper; - } - - #region Public Methods - - public async Task> GetAllConversationsByUserIdAsync(string userId) - { - var conversations = await _unitOfWork.Conversations.GetAllByUserIdAsync(userId); - - return conversations; - } - - public async Task GetConversationByIdAsync(string conversationId, string userId) - { - var conversation = await _unitOfWork.Conversations.GetByIdAsync(conversationId); - - if (conversation == null) - { - throw new NotFoundException("Conversation with the given ID {conversationId} not found"); - } - - if (conversation.UserId != userId) - { - throw new UnauthorizedAccessException("User does not have access to this conversation."); - } - - return conversation; - } - - public async Task CreateConversationAsync(CreateConversationRequest createConversationRequest, string userId) - { - var conversation = _mapper.Map(createConversationRequest); - - conversation.UserId = userId; - - var createdConversation = await _unitOfWork.Conversations.AddAsync(conversation); - - await _unitOfWork.SaveGuruChangesAsync(); - - return createdConversation; - } - - public async Task UpdateConversationAsync(string conversationId, string userId, UpdateConversationRequest updateConversationRequest) - { - var conversation = await GetConversationByIdAsync(conversationId, userId); - var updatedConversation = _mapper.Map(updateConversationRequest, conversation); - - _unitOfWork.Conversations.Update(conversation); - - await _unitOfWork.SaveGuruChangesAsync(); - - return conversation; - } - - public async Task DeleteConversationAsync(string conversationId) - { - var conversation = await _unitOfWork.Conversations.GetByIdAsync(conversationId); - if (conversation == null) - { - throw new NotFoundException("Conversation with the given ID {conversationId} not found"); - } - - await _unitOfWork.Messages.DeleteConversationMessagesAsync(conversationId); - _unitOfWork.Conversations.Delete(conversation); - - var result = await _unitOfWork.SaveGuruChangesAsync(); - return result > 0; - } - - public async Task RunAgentWorkflowAsync(AgentExecutionRequest request, string userId) - { - var startTime = DateTime.UtcNow; - - var agent = await GetActiveAgentAsync(request.AgentId); - var conversation = await ValidateConversationAsync(request.ConversationId, userId); - var messages = await _unitOfWork.Messages.GetMessagesAsync(conversation.Id, - agent.MemoryConfiguration.MaxContextMessages); - - var result = await _aiChatProvider.ExecuteAsync(agent, conversation, messages, request.Message); - - await SaveMessagesAsync(conversation, request.Message, result); - - await HandleSummaryAsync(agent, conversation); - } - - #endregion - - #region Private Methods - - private async Task GetActiveAgentAsync(string agentId) - { - var agent = await _unitOfWork.Agents.GetByIdAsync(agentId); - if (agent == null) - { - throw new NotFoundException($"Agent with ID {agentId} not found."); - } - - if (agent.Status != AgentStatus.Active) - { - throw new InvalidOperationException($"Agent with ID {agentId} is not active."); - } - - return agent; - } - - private async Task ValidateConversationAsync(string conversationId, string userId) - { - var conversation = await _unitOfWork.Conversations.GetByIdAsync(conversationId); - - if (conversation == null) - { - throw new NotFoundException($"Conversation with ID {conversationId} not found."); - } - - if (conversation.UserId != userId) - { - throw new UnauthorizedAccessException("User does not have access to this conversation."); - } - - // Add admin check - - return conversation; - } - - private async Task SaveMessagesAsync(Conversation conversation, string userMessageContent, AgentExecutionResult result) - { - var userMessage = new Message - { - ConversationId = conversation.Id, - Role = "User", - Content = userMessageContent, - CreatedAt = DateTime.UtcNow, - MetaData = new MessageMetadata - { - TokenCount = result.InputTokens - } - }; - var agentMessage = new Message - { - ConversationId = conversation.Id, - Role = "Assistant", - Content = result.Content, - ToolCalls = result.ToolCalls, - CreatedAt = DateTime.UtcNow, - MetaData = new MessageMetadata - { - AgentName = result.AgentName, - TokenCount = result.OutputTokens, - ProcessingTime = result.ProcessingTime, - ModelUsed = result.ModelId - } - }; - - await _unitOfWork.Messages.AddAsync(userMessage); - await _unitOfWork.Messages.AddAsync(agentMessage); - - conversation.UpdatedAt = DateTime.UtcNow; - conversation.Metadata.TotalMessages += 2; - conversation.Metadata.TotalTokens += result.TotalTokens; - - _unitOfWork.Conversations.Update(conversation); - - await _unitOfWork.SaveGuruChangesAsync(); - } - - private async Task HandleSummaryAsync(Agent agent, Conversation conversation) - { - if (agent.MemoryConfiguration.EnableSummary && - conversation.Metadata.TotalMessages >= agent.MemoryConfiguration.SummaryThresholdMessages) - { - var messages = await _unitOfWork.Messages.GetMessagesAsync(conversation.Id, agent.MemoryConfiguration.MaxContextMessages); - - var updatedSummary = await _aiChatProvider.GenerateSummaryAsync(messages, conversation.Metadata.Summary); - - if (string.IsNullOrEmpty(updatedSummary)) - { - _logger.LogWarning("Summary generation returned empty for conversation {ConversationId}", conversation.Id); - - return; - } - - conversation.Metadata.Summary = updatedSummary ?? conversation.Metadata.Summary; - - _unitOfWork.Conversations.Update(conversation); - - await _unitOfWork.SaveGuruChangesAsync(); - } - } - - #endregion -} diff --git a/src/GuruPR.Application/Services/MessageService.cs b/src/GuruPR.Application/Services/MessageService.cs deleted file mode 100644 index e22dcb6..0000000 --- a/src/GuruPR.Application/Services/MessageService.cs +++ /dev/null @@ -1,35 +0,0 @@ -using GuruPR.Application.Exceptions; -using GuruPR.Application.Interfaces.Application; -using GuruPR.Application.Interfaces.Persistence; -using GuruPR.Domain.Entities.Message; - -namespace GuruPR.Application.Services; - -public class MessageService : IMessageService -{ - private readonly IUnitOfWork _unitOfWork; - - public MessageService(IUnitOfWork unitOfWork) - { - _unitOfWork = unitOfWork; - } - - public async Task> GetMessagesByConversationIdAsync(string conversationId, string userId) - { - var conversation = await _unitOfWork.Conversations.GetByIdAsync(conversationId); - - if (conversation == null) - { - throw new NotFoundException($"Conversation with given ID {conversationId} not found."); - } - - if (conversation.UserId != userId) - { - throw new UnauthorizedAccessException("User does not have access to this conversation."); - } - - var messages = await _unitOfWork.Messages.GetMessagesAsync(conversationId); - - return messages; - } -} diff --git a/src/GuruPR.Application/Services/OAuth/ProviderConnectionService.cs b/src/GuruPR.Application/Services/OAuth/ProviderConnectionService.cs deleted file mode 100644 index 33486f8..0000000 --- a/src/GuruPR.Application/Services/OAuth/ProviderConnectionService.cs +++ /dev/null @@ -1,146 +0,0 @@ -using AutoMapper; - -using GuruPR.Application.Dtos.OAuth.ProviderConnection; -using GuruPR.Application.Exceptions; -using GuruPR.Application.Interfaces.Application; -using GuruPR.Application.Interfaces.Persistence; -using GuruPR.Domain.Entities.Enums; -using GuruPR.Domain.Entities.OAuth; - -namespace GuruPR.Application.Services.OAuth; - -public class ProviderConnectionService : IProviderConnectionService -{ - private readonly IMapper _mapper; - private readonly IUnitOfWork _unitOfWork; - - public ProviderConnectionService(IMapper mapper, IUnitOfWork unitOfWork) - { - _mapper = mapper; - _unitOfWork = unitOfWork; - } - - #region Public Methods - - public async Task> GetConnectionsByProviderIdAsync(string providerId) - { - var provider = await GetProviderByIdOrThrowExceptionAsync(providerId); - - return provider.ProviderConnections ?? []; - } - - public async Task GetProviderConnectionByIdAsync(string providerId, string providerConnectionId) - { - var provider = await GetProviderByIdOrThrowExceptionAsync(providerId); - var providerConnection = provider.ProviderConnections.FirstOrDefault(providerConnection => providerConnection.Id == providerConnectionId); - - if (providerConnection is null) - { - throw new NotFoundException($"Provider connection with ID '{providerConnectionId}' not found for provider with ID '{providerId}'."); - } - - return providerConnection; - } - - public async Task GetProviderConnectionByScopeAndProviderNameAsync(string providerName, string scope) - { - var provider = await _unitOfWork.Providers.GetProviderByNameAsync(providerName); - - if (provider is null) - { - throw new NotFoundException($"Provider with name '{providerName}' not found."); - } - - var providerConnection = provider?.ProviderConnections.FirstOrDefault(pc => pc.HasScope(scope)); - - if (providerConnection is null) - { - throw new NotFoundException($"Provider connection with scope '{scope}' for provider '{providerName}' not found."); - } - - return providerConnection; - } - - public async Task GetProviderConnectionByProviderTypeAndScopeAsync(OAuthProviderType OAuthProviderType, string scope) - { - var provider = await GetProviderByTypeOrThrowExceptionAsync(OAuthProviderType); - var providerConnection = provider?.ProviderConnections.FirstOrDefault(pc => pc.HasScope(scope)); - - if (providerConnection is null) - { - throw new NotFoundException($"Provider connection with scope '{scope}' for provider type '{OAuthProviderType}' not found."); - } - - return providerConnection; - } - - - public async Task AddProviderConnectionToProviderAsync(string providerId, CreateProviderConnectionRequest createProviderConnectionRequest) - { - var provider = await GetProviderByIdOrThrowExceptionAsync(providerId); - var providerConnection = _mapper.Map(createProviderConnectionRequest); - - provider.AddProviderConnection(providerConnection); - await _unitOfWork.SaveGuruChangesAsync(); - - return providerConnection; - } - - public async Task UpdateProviderConnectionByProviderTypeAsync(OAuthProviderType OAuthProviderType, - string providerConnectionId, - UpdateProviderConnectionRequest updateProviderConnectionRequest) - { - var provider = await GetProviderByTypeOrThrowExceptionAsync(OAuthProviderType); - var providerConnection = await GetProviderConnectionByIdAsync(provider.Id, providerConnectionId); - - _mapper.Map(updateProviderConnectionRequest, providerConnection); - await _unitOfWork.SaveGuruChangesAsync(); - - return providerConnection; - } - - public async Task DeleteProviderConnectionAsync(string providerId, string providerConnectionId) - { - var provider = await GetProviderByIdOrThrowExceptionAsync(providerId); - - var removed = provider.RemoveProviderConnection(providerConnectionId); - - if (!removed) - { - return false; - } - - await _unitOfWork.SaveGuruChangesAsync(); - - return true; - } - - #endregion - - #region Private Methods - - private async Task GetProviderByIdOrThrowExceptionAsync(string providerId) - { - var provider = await _unitOfWork.Providers.GetByIdAsync(providerId); - if (provider is null) - { - throw new NotFoundException($"Provider with ID {providerId} not found."); - } - - return provider; - } - - private async Task GetProviderByTypeOrThrowExceptionAsync(OAuthProviderType OAuthProviderType) - { - var provider = await _unitOfWork.Providers.GetProviderByTypeAsync(OAuthProviderType); - - if (provider is null) - { - throw new NotFoundException($"Provider with type '{OAuthProviderType}' not found."); - } - - return provider; - } - - #endregion -} diff --git a/src/GuruPR.Application/Services/OAuth/ProviderService.cs b/src/GuruPR.Application/Services/OAuth/ProviderService.cs deleted file mode 100644 index ef95593..0000000 --- a/src/GuruPR.Application/Services/OAuth/ProviderService.cs +++ /dev/null @@ -1,173 +0,0 @@ -using AutoMapper; - -using GuruPR.Application.Dtos.OAuth.Provider; -using GuruPR.Application.Exceptions; -using GuruPR.Application.Interfaces.Application; -using GuruPR.Application.Interfaces.Persistence; -using GuruPR.Domain.Entities.Enums; -using GuruPR.Domain.Entities.OAuth; -using GuruPR.Domain.Exceptions; - -namespace GuruPR.Application.Services.OAuth; - -/// -/// Service for managing OAuth providers and their connections, -/// including creation, retrieval, updating, and deletion. -/// -public class ProviderService : IProviderService -{ - private readonly IMapper _mapper; - private readonly IUnitOfWork _unitOfWork; - - public ProviderService(IMapper mapper, IUnitOfWork unitOfWork) - { - _mapper = mapper; - _unitOfWork = unitOfWork; - } - - #region Public Methods - - /// - /// Creates a new OAuth provider and saves it to the database. - /// - /// The provider entity to create. - /// The created provider entity. - /// Thrown when the provider configuration is invalid. - public async Task CreateProviderAsync(CreateProviderRequest createProviderRequest) - { - var provider = _mapper.Map(createProviderRequest); - if (!provider.IsValid()) - { - throw new DomainException("Invalid provider configuration, please recheck provider configuration."); - } - - await _unitOfWork.Providers.AddAsync(provider); - await _unitOfWork.SaveGuruChangesAsync(); - - return provider; - } - - /// - /// Retrieves all OAuth providers from the database. - /// - /// A collection of provider entities. - public async Task> GetAllProvidersAsync() - { - var providers = await _unitOfWork.Providers.GetAllAsync(); - - return providers; - } - - /// - /// Retrieves an OAuth provider by its unique identifier. - /// - /// The unique identifier of the provider. - /// The provider entity. - /// Thrown when the provider is not found. - public async Task GetProviderByIdAsync(string providerId) - { - var provider = await GetProviderOrThrowAsync(providerId); - - return provider; - } - - /// - /// Retrieves an OAuth provider by its name. - /// - /// The name of the provider. - /// The provider entity. - /// Thrown when the provider is not found. - public async Task GetProviderByNameAsync(string providerName) - { - var provider = await _unitOfWork.Providers.GetProviderByNameAsync(providerName); - - if (provider == null) - { - throw new NotFoundException($"Provider with name {providerName} not found."); - } - - return provider; - } - - /// - /// Retrieves an OAuth provider by its type. - /// - /// The type of the provider. - /// The provider entity. - /// Thrown when the provider is not found. - public async Task GetProviderByTypeAsync(OAuthProviderType OAuthProviderType) - { - var provider = await _unitOfWork.Providers.GetProviderByTypeAsync(OAuthProviderType); - - if (provider == null) - { - throw new NotFoundException($"Provider with type {OAuthProviderType} not found."); - } - - return provider; - } - - /// - /// Updates an existing OAuth provider. - /// - /// The ID of the provider to update. - /// Request containing updated provider details. - /// The updated provider entity. - /// Thrown when the updated provider configuration is invalid. - /// Thrown when the provider is not found. - public async Task UpdateProviderAsync(string providerId, UpdateProviderRequest updateProviderRequest) - { - var provider = await GetProviderOrThrowAsync(providerId); - - _mapper.Map(updateProviderRequest, provider); - - if (!provider.IsValid()) - { - throw new DomainException("Invalid provider configuration, please recheck provider configuration."); - } - - _unitOfWork.Providers.Update(provider); - await _unitOfWork.SaveGuruChangesAsync(); - - return provider; - } - - /// - /// Deletes an OAuth provider by its unique identifier. - /// - /// The unique identifier of the provider to delete. - /// True if the provider was successfully deleted; otherwise, false. - /// Thrown when the provider is not found. - public async Task DeleteProviderAsync(string providerId) - { - var provider = await GetProviderOrThrowAsync(providerId); - - _unitOfWork.Providers.Delete(provider); - - var result = await _unitOfWork.SaveGuruChangesAsync(); - return result > 0; - } - - #endregion - - #region Private Methods - - /// - /// Retrieves an OAuth provider by its unique identifier or throws an exception if not found. - /// - /// The unique identifier of the provider. - /// The provider entity. - /// Thrown when the provider is not found. - private async Task GetProviderOrThrowAsync(string providerId) - { - var provider = await _unitOfWork.Providers.GetByIdAsync(providerId); - if (provider == null) - { - throw new NotFoundException($"Provider with ID {providerId} not found."); - } - - return provider; - } - - #endregion -} diff --git a/src/GuruPR.Application/Services/ToolService.cs b/src/GuruPR.Application/Services/ToolService.cs deleted file mode 100644 index ead8f4b..0000000 --- a/src/GuruPR.Application/Services/ToolService.cs +++ /dev/null @@ -1,7 +0,0 @@ -using GuruPR.Application.Interfaces.Application; - -namespace GuruPR.Application.Services; - -public class ToolService : IToolService -{ -} diff --git a/src/GuruPR.Domain/Entities/Agent.cs b/src/GuruPR.Domain/Entities/Agent.cs deleted file mode 100644 index a4b4ff9..0000000 --- a/src/GuruPR.Domain/Entities/Agent.cs +++ /dev/null @@ -1,121 +0,0 @@ -using GuruPR.Domain.Entities.Configurations; -using GuruPR.Domain.Entities.Configurations.Enums; -using GuruPR.Domain.Errors; - -namespace GuruPR.Domain.Entities; - -public class Agent -{ - // Basic Info - public string Id { get; set; } = Guid.NewGuid().ToString(); - public string Name { get; set; } = null!; - public string AvatarUrl { get; set; } = null!; - public string Description { get; set; } = null!; - public string Instrunctions { get; set; } = null!; - - // Tools - public IList Tools { get; set; } = null!; - - // Model Configuration - public ModelConfiguration ModelConfiguration { get; set; } = null!; - - // Memory Settings - public MemoryConfiguration MemoryConfiguration { get; set; } = null!; - - // Metadata - public string CreatedByUserId { get; set; } = null!; - public AgentStatus Status { get; set; } = AgentStatus.Inactive; - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - - public bool IsValid() => !Validate().Any(); - - // Validation Method - public IEnumerable Validate() - { - var errors = new List(); - - if (string.IsNullOrWhiteSpace(Id) || !Guid.TryParse(Id, out _)) - { - errors.Add(new ValidationError - { - Field = nameof(Id), - Message = "Invalid or missing Id." - }); - } - - if (string.IsNullOrWhiteSpace(Name)) - { - errors.Add(new ValidationError - { - Field = nameof(Name), - Message = "Name is required." - }); - } - - if (string.IsNullOrWhiteSpace(AvatarUrl) || !Uri.IsWellFormedUriString(AvatarUrl, UriKind.Absolute)) - { - errors.Add(new ValidationError - { - Field = nameof(AvatarUrl), - Message = "AvatarUrl must be a valid absolute URL." - }); - } - - if (string.IsNullOrWhiteSpace(Description) || Description.Length < 5) - { - errors.Add(new ValidationError - { - Field = nameof(Description), - Message = "Description is required and must be at least 5 characters." - }); - } - - if (string.IsNullOrWhiteSpace(Instrunctions)) - { - errors.Add(new ValidationError - { - Field = nameof(Instrunctions), - Message = "Instructions are required." - }); - } - - if (Tools == null || Tools.Count == 0) - { - errors.Add(new ValidationError - { - Field = nameof(Tools), - Message = "At least one tool is required." - }); - } - - if (ModelConfiguration == null) - { - errors.Add(new ValidationError - { - Field = nameof(ModelConfiguration), - Message = "Model configuration is required." - }); - } - else - { - errors.AddRange(ModelConfiguration.Validate()); - } - - // Memory configuration is optional - if (MemoryConfiguration != null) - { - errors.AddRange(MemoryConfiguration.Validate()); - } - - if (string.IsNullOrWhiteSpace(CreatedByUserId)) - { - errors.Add(new ValidationError - { - Field = nameof(CreatedByUserId), - Message = "CreatedBy user ID is required." - }); - } - - return errors; - } -} diff --git a/src/GuruPR.Domain/Entities/Agents/Agent.cs b/src/GuruPR.Domain/Entities/Agents/Agent.cs new file mode 100644 index 0000000..44eea97 --- /dev/null +++ b/src/GuruPR.Domain/Entities/Agents/Agent.cs @@ -0,0 +1,47 @@ +using GuruPR.Domain.Entities.Agents.Configurations; +using GuruPR.Domain.Entities.Agents.Enums; +using GuruPR.Domain.Entities.Agents.Operations; +using GuruPR.Domain.Interfaces.Markers; + +namespace GuruPR.Domain.Entities.Agents; + +public class Agent : IOwnedEntity +{ + // Basic Info + public string Id { get; set; } = Guid.NewGuid().ToString(); + public string Name { get; set; } = null!; + public string AvatarUrl { get; set; } = null!; + public string Description { get; set; } = null!; + public string Instructions { get; set; } = null!; + + // Tools + public IList Tools { get; set; } = null!; + + // Model Configuration + public ModelConfiguration ModelConfiguration { get; set; } = null!; + + // Memory Settings + public MemoryConfiguration MemoryConfiguration { get; set; } = null!; + + // Metadata + public string UserId { get; set; } = null!; + public AgentStatus Status { get; set; } = AgentStatus.Inactive; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public bool IsActive() => Status == AgentStatus.Active; + + public void Update(AgentUpdateData agentUpdateData) + { + Name = agentUpdateData.Name; + AvatarUrl = agentUpdateData.AvatarUrl; + Description = agentUpdateData.Description; + Instructions = agentUpdateData.Instructions; + + Tools = agentUpdateData.Tools; + + ModelConfiguration = agentUpdateData.ModelConfiguration; + MemoryConfiguration = agentUpdateData.MemoryConfiguration; + + Status = agentUpdateData.Status; + } +} diff --git a/src/GuruPR.Domain/Entities/Configurations/MemoryConfiguration.cs b/src/GuruPR.Domain/Entities/Agents/Configurations/MemoryConfiguration.cs similarity index 96% rename from src/GuruPR.Domain/Entities/Configurations/MemoryConfiguration.cs rename to src/GuruPR.Domain/Entities/Agents/Configurations/MemoryConfiguration.cs index 00f4766..56fe07d 100644 --- a/src/GuruPR.Domain/Entities/Configurations/MemoryConfiguration.cs +++ b/src/GuruPR.Domain/Entities/Agents/Configurations/MemoryConfiguration.cs @@ -1,7 +1,7 @@ -using GuruPR.Domain.Entities.Configurations.Enums; +using GuruPR.Domain.Entities.Agents.Enums; using GuruPR.Domain.Errors; -namespace GuruPR.Domain.Entities.Configurations; +namespace GuruPR.Domain.Entities.Agents.Configurations; public class MemoryConfiguration { diff --git a/src/GuruPR.Domain/Entities/Agents/Configurations/ModelConfiguration.cs b/src/GuruPR.Domain/Entities/Agents/Configurations/ModelConfiguration.cs new file mode 100644 index 0000000..410f7aa --- /dev/null +++ b/src/GuruPR.Domain/Entities/Agents/Configurations/ModelConfiguration.cs @@ -0,0 +1,26 @@ +namespace GuruPR.Domain.Entities.Agents.Configurations; + +public class ModelConfiguration +{ + public string Provider { get; set; } = null!; + + public string ModelType { get; set; } = null!; + + public string ModelName { get; set; } = null!; + + public double Temperature { get; set; } = 0.6; + + public int MaxTokens { get; set; } = 2000; + + public double TopP { get; set; } = 0.9; + + public double FrequencyPenalty { get; set; } = 0.0; + + public double PresencePenalty { get; set; } = 0.0; + + public IList StopSequences { get; set; } = new List(); + + public Dictionary AdditionalParameters { get; set; } = new Dictionary(); + + public bool IsReasoningModel() => ModelType?.Contains("Reasoning", StringComparison.OrdinalIgnoreCase) ?? false; +} diff --git a/src/GuruPR.Domain/Entities/Configurations/Enums/AgentStatus.cs b/src/GuruPR.Domain/Entities/Agents/Enums/AgentStatus.cs similarity index 72% rename from src/GuruPR.Domain/Entities/Configurations/Enums/AgentStatus.cs rename to src/GuruPR.Domain/Entities/Agents/Enums/AgentStatus.cs index 7b7e427..89eded0 100644 --- a/src/GuruPR.Domain/Entities/Configurations/Enums/AgentStatus.cs +++ b/src/GuruPR.Domain/Entities/Agents/Enums/AgentStatus.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace GuruPR.Domain.Entities.Configurations.Enums; +namespace GuruPR.Domain.Entities.Agents.Enums; [JsonConverter(typeof(JsonStringEnumConverter))] public enum AgentStatus diff --git a/src/GuruPR.Domain/Entities/Configurations/Enums/MemoryType.cs b/src/GuruPR.Domain/Entities/Agents/Enums/MemoryType.cs similarity index 74% rename from src/GuruPR.Domain/Entities/Configurations/Enums/MemoryType.cs rename to src/GuruPR.Domain/Entities/Agents/Enums/MemoryType.cs index 9f85769..2e2deeb 100644 --- a/src/GuruPR.Domain/Entities/Configurations/Enums/MemoryType.cs +++ b/src/GuruPR.Domain/Entities/Agents/Enums/MemoryType.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace GuruPR.Domain.Entities.Configurations.Enums; +namespace GuruPR.Domain.Entities.Agents.Enums; [JsonConverter(typeof(JsonStringEnumConverter))] public enum MemoryType diff --git a/src/GuruPR.Application/Dtos/Agent/CreateAgentRequest.cs b/src/GuruPR.Domain/Entities/Agents/Operations/AgentUpdateData.cs similarity index 68% rename from src/GuruPR.Application/Dtos/Agent/CreateAgentRequest.cs rename to src/GuruPR.Domain/Entities/Agents/Operations/AgentUpdateData.cs index 6751898..521a8a1 100644 --- a/src/GuruPR.Application/Dtos/Agent/CreateAgentRequest.cs +++ b/src/GuruPR.Domain/Entities/Agents/Operations/AgentUpdateData.cs @@ -1,14 +1,14 @@ -using GuruPR.Domain.Entities.Configurations; -using GuruPR.Domain.Entities.Configurations.Enums; +using GuruPR.Domain.Entities.Agents.Configurations; +using GuruPR.Domain.Entities.Agents.Enums; -namespace GuruPR.Application.Dtos.Agent; +namespace GuruPR.Domain.Entities.Agents.Operations; -public class CreateAgentRequest +public class AgentUpdateData { public string Name { get; set; } = null!; public string AvatarUrl { get; set; } = null!; public string Description { get; set; } = null!; - public string Instrunctions { get; set; } = null!; + public string Instructions { get; set; } = null!; // Tools public IList Tools { get; set; } = null!; diff --git a/src/GuruPR.Domain/Entities/Configurations/ModelConfiguration.cs b/src/GuruPR.Domain/Entities/Configurations/ModelConfiguration.cs deleted file mode 100644 index 6156284..0000000 --- a/src/GuruPR.Domain/Entities/Configurations/ModelConfiguration.cs +++ /dev/null @@ -1,109 +0,0 @@ -using GuruPR.Domain.Errors; - -namespace GuruPR.Domain.Entities.Configurations; - -public class ModelConfiguration -{ - public string Provider { get; set; } = null!; - - public string ModelType { get; set; } = null!; - - public string ModelName { get; set; } = null!; - - public double Temperature { get; set; } = 0.6; - - public int MaxTokens { get; set; } = 2000; - - public double TopP { get; set; } = 0.9; - - public double FrequencyPenalty { get; set; } = 0.0; - - public double PresencePenalty { get; set; } = 0.0; - - public IList StopSequences { get; set; } = new List(); - - public Dictionary AdditionalParameters { get; set; } = new Dictionary(); - - public bool IsValid() => !Validate().Any(); - - public IEnumerable Validate() - { - var errors = new List(); - - if (string.IsNullOrWhiteSpace(Provider)) - { - errors.Add(new ValidationError - { - Field = nameof(Provider), - Message = "Model provider is required." - }); - } - - if (string.IsNullOrWhiteSpace(ModelType)) - { - errors.Add(new ValidationError - { - Field = nameof(ModelType), - Message = "Model type is required." - }); - } - - if (string.IsNullOrWhiteSpace(ModelName)) - { - errors.Add(new ValidationError - { - Field = nameof(ModelName), - Message = "Model name is required." - }); - } - - if (Temperature < 0) - { - errors.Add(new ValidationError - { - Field = nameof(Temperature), - Message = "Temperature must be greater than or equal to 0 and preferably less than or equal to 1." - }); - } - - if (TopP < 0 || TopP > 1) - { - errors.Add(new ValidationError - { - Field = nameof(TopP), - Message = "TopP must be between 0 and 1." - }); - } - - if (MaxTokens <= 0) - { - errors.Add(new ValidationError - { - Field = nameof(MaxTokens), - Message = "MaxTokens must be greater than 0." - }); - } - - if (FrequencyPenalty < 0 || FrequencyPenalty > 2) - { - errors.Add(new ValidationError - { - Field = nameof(FrequencyPenalty), - Message = "FrequencyPenalty must be between 0 and 2." - }); - } - - if (PresencePenalty < 0 || PresencePenalty > 2) - { - errors.Add(new ValidationError - { - Field = nameof(PresencePenalty), - Message = "PresencePenalty must be between 0 and 2." - }); - } - - return errors; - } - - public bool IsReasoningModel() => ModelType?.Contains("Reasoning", StringComparison.OrdinalIgnoreCase) ?? false; -} diff --git a/src/GuruPR.Domain/Entities/Conversation/Conversation.cs b/src/GuruPR.Domain/Entities/Conversation/Conversation.cs index c27e513..c355fbb 100644 --- a/src/GuruPR.Domain/Entities/Conversation/Conversation.cs +++ b/src/GuruPR.Domain/Entities/Conversation/Conversation.cs @@ -1,6 +1,9 @@ -namespace GuruPR.Domain.Entities.Conversation; +using GuruPR.Domain.Entities.Conversation.Operations; +using GuruPR.Domain.Interfaces.Markers; -public class Conversation +namespace GuruPR.Domain.Entities.Conversation; + +public class Conversation : IOwnedEntity { public string Id { get; set; } = Guid.NewGuid().ToString(); @@ -10,11 +13,17 @@ public class Conversation public string Title { get; set; } = null!; - public Dictionary State { get; set; } = null!; - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; - public ConversationMetadata Metadata { get; set; } = null!; + public ConversationMetadata? Metadata { get; set; } = new ConversationMetadata(); + + public void Update(ConversationUpdateData conversationUpdateData) + { + Title = conversationUpdateData.Title; + AgentId = conversationUpdateData.AgentId; + + UpdatedAt = DateTime.UtcNow; + } } diff --git a/src/GuruPR.Domain/Entities/Conversation/Operations/ConversationUpdateData.cs b/src/GuruPR.Domain/Entities/Conversation/Operations/ConversationUpdateData.cs new file mode 100644 index 0000000..b2c34b1 --- /dev/null +++ b/src/GuruPR.Domain/Entities/Conversation/Operations/ConversationUpdateData.cs @@ -0,0 +1,8 @@ +namespace GuruPR.Domain.Entities.Conversation.Operations; + +public class ConversationUpdateData +{ + public string AgentId { get; set; } = null!; + + public string Title { get; set; } = null!; +} diff --git a/src/GuruPR.Domain/Entities/Enums/OAuthProviderType.cs b/src/GuruPR.Domain/Entities/Enums/OAuthProviderType.cs deleted file mode 100644 index e5c7c33..0000000 --- a/src/GuruPR.Domain/Entities/Enums/OAuthProviderType.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace GuruPR.Domain.Entities.Enums; - -public enum OAuthProviderType -{ - Spotify, - Discord -} diff --git a/src/GuruPR.Domain/Entities/Provider/Enums/OAuthProviderType.cs b/src/GuruPR.Domain/Entities/Provider/Enums/OAuthProviderType.cs new file mode 100644 index 0000000..ccbb80c --- /dev/null +++ b/src/GuruPR.Domain/Entities/Provider/Enums/OAuthProviderType.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; + +namespace GuruPR.Domain.Entities.Provider.Enums; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum OAuthProviderType +{ + Spotify, + Discord, + Other +} diff --git a/src/GuruPR.Application/Dtos/OAuth/Provider/UpdateProviderRequest.cs b/src/GuruPR.Domain/Entities/Provider/Operations/ProviderUpdateData.cs similarity index 53% rename from src/GuruPR.Application/Dtos/OAuth/Provider/UpdateProviderRequest.cs rename to src/GuruPR.Domain/Entities/Provider/Operations/ProviderUpdateData.cs index b6b5df7..808d558 100644 --- a/src/GuruPR.Application/Dtos/OAuth/Provider/UpdateProviderRequest.cs +++ b/src/GuruPR.Domain/Entities/Provider/Operations/ProviderUpdateData.cs @@ -1,8 +1,8 @@ -using GuruPR.Domain.Entities.Enums; +using GuruPR.Domain.Entities.Provider.Enums; -namespace GuruPR.Application.Dtos.OAuth.Provider; +namespace GuruPR.Domain.Entities.Provider.Operations; -public class UpdateProviderRequest +public class ProviderUpdateData { public string? DisplayName { get; init; } @@ -11,6 +11,4 @@ public class UpdateProviderRequest public string? AuthorizationUrl { get; init; } public string? TokenUrl { get; init; } - - public List? DefaultScopes { get; init; } } diff --git a/src/GuruPR.Domain/Entities/OAuth/Provider.cs b/src/GuruPR.Domain/Entities/Provider/Provider.cs similarity index 58% rename from src/GuruPR.Domain/Entities/OAuth/Provider.cs rename to src/GuruPR.Domain/Entities/Provider/Provider.cs index f0e7a5f..fc29787 100644 --- a/src/GuruPR.Domain/Entities/OAuth/Provider.cs +++ b/src/GuruPR.Domain/Entities/Provider/Provider.cs @@ -1,6 +1,7 @@ -using GuruPR.Domain.Entities.Enums; +using GuruPR.Domain.Entities.Provider.Enums; +using GuruPR.Domain.Entities.Provider.Operations; -namespace GuruPR.Domain.Entities.OAuth; +namespace GuruPR.Domain.Entities.Provider; /// /// Represents an OAuth provider with its configuration details. @@ -42,18 +43,13 @@ public class Provider /// public required DateTime CreatedAt { get; set; } = DateTime.UtcNow; - /// - /// List of connections associated with this provider. - /// - public List ProviderConnections { get; set; } = []; - /// /// Validates the provider configuration. /// public bool IsValid() { if (string.IsNullOrWhiteSpace(DisplayName)) return false; - if (!Enum.IsDefined(ProviderType)) return false; + if (!Enum.IsDefined(ProviderType)) return false; if (!Uri.IsWellFormedUriString(AuthorizationUrl, UriKind.Absolute)) return false; if (!Uri.IsWellFormedUriString(TokenUrl, UriKind.Absolute)) return false; if (DefaultScopes == null || DefaultScopes.Count == 0) return false; @@ -61,29 +57,11 @@ public bool IsValid() return true; } - public void AddProviderConnection(ProviderConnection providerConnection) + public void Update(ProviderUpdateData providerUpdateData) { - if (providerConnection == null) - { - throw new ArgumentNullException(nameof(providerConnection)); - } - - ProviderConnections.Add(providerConnection); - } - - public bool RemoveProviderConnection(string providerConnectionId) - { - if (string.IsNullOrWhiteSpace(providerConnectionId)) - { - throw new ArgumentException("Provider connection ID cannot be null or empty.", nameof(providerConnectionId)); - } - - var providerConnection = ProviderConnections.FirstOrDefault(providerConnection => providerConnection.Id == providerConnectionId); - if (providerConnection != null) - { - return ProviderConnections.Remove(providerConnection); - } - - return false; + DisplayName = providerUpdateData.DisplayName!; + ProviderType = providerUpdateData.ProviderType ?? OAuthProviderType.Other; + AuthorizationUrl = providerUpdateData.AuthorizationUrl!; + TokenUrl = providerUpdateData.TokenUrl!; } } diff --git a/src/GuruPR.Domain/Entities/ProviderConnection/Operations/ProviderConnectionUpdateData.cs b/src/GuruPR.Domain/Entities/ProviderConnection/Operations/ProviderConnectionUpdateData.cs new file mode 100644 index 0000000..0b954d7 --- /dev/null +++ b/src/GuruPR.Domain/Entities/ProviderConnection/Operations/ProviderConnectionUpdateData.cs @@ -0,0 +1,10 @@ +namespace GuruPR.Domain.Entities.ProviderConnection.Operations; + +public class ProviderConnectionUpdateData +{ + public required string ClientId { get; init; } + + public required string ClientSecret { get; init; } + + public required List Scopes { get; init; } +} diff --git a/src/GuruPR.Domain/Entities/OAuth/ProviderConnection.cs b/src/GuruPR.Domain/Entities/ProviderConnection/ProviderConnection.cs similarity index 76% rename from src/GuruPR.Domain/Entities/OAuth/ProviderConnection.cs rename to src/GuruPR.Domain/Entities/ProviderConnection/ProviderConnection.cs index a30321a..b38d00c 100644 --- a/src/GuruPR.Domain/Entities/OAuth/ProviderConnection.cs +++ b/src/GuruPR.Domain/Entities/ProviderConnection/ProviderConnection.cs @@ -1,4 +1,6 @@ -namespace GuruPR.Domain.Entities.OAuth; +using GuruPR.Domain.Entities.ProviderConnection.Operations; + +namespace GuruPR.Domain.Entities.ProviderConnection; /// /// Represents a connection to an OAuth provider, including tokens, scopes, and expiration details. @@ -10,6 +12,11 @@ public class ProviderConnection /// public string Id { get; set; } = Guid.NewGuid().ToString(); + /// + /// Identifier of the associated OAuth provider. + /// + public required string ProviderId { get; set; } + /// /// Client ID used for OAuth authentication. /// @@ -52,4 +59,11 @@ public class ProviderConnection public bool HasScope(string scope) => Scopes.Any(selectedScope => string.Equals(selectedScope, scope, StringComparison.OrdinalIgnoreCase)); public bool ShouldRefreshToken(int bufferSeconds = 60) => IsTokenExpired(bufferSeconds) && !string.IsNullOrEmpty(RefreshToken); + + public void Update(ProviderConnectionUpdateData providerConnectionUpdateData) + { + ClientId = providerConnectionUpdateData.ClientId; + ClientSecret = providerConnectionUpdateData.ClientSecret; + Scopes = providerConnectionUpdateData.Scopes; + } } diff --git a/src/GuruPR.Domain/Entities/User.cs b/src/GuruPR.Domain/Entities/User.cs index 303fadd..d26010d 100644 --- a/src/GuruPR.Domain/Entities/User.cs +++ b/src/GuruPR.Domain/Entities/User.cs @@ -15,4 +15,42 @@ public class User : IdentityUser private string FullName => $"{FirstName} {LastName}".Trim(); public override string ToString() => FullName; + + public void UpdateRefreshToken(string tokenHash, DateTime expiryTime) + { + if (string.IsNullOrWhiteSpace(tokenHash)) + { + throw new ArgumentException("Token hash cannot be empty.", nameof(tokenHash)); + } + + if (RefreshTokenHash != null && expiryTime <= DateTime.UtcNow) + { + throw new ArgumentException("Expiry time must be in the future.", nameof(expiryTime)); + } + + RefreshTokenHash = tokenHash; + RefreshTokenExpiryTime = expiryTime; + } + + public void ClearRefreshToken() + { + RefreshTokenHash = null; + RefreshTokenExpiryTime = null; + } + + public bool NeedsRefreshTokenRenewal(int renewalThresholdDays = 7) + { + if (RefreshTokenExpiryTime == null) + { + return true; + } + + var renewalThreshold = DateTime.UtcNow.AddDays(renewalThresholdDays); + return RefreshTokenExpiryTime < renewalThreshold; + } + + public bool IsRefreshTokenExpired() + { + return RefreshTokenExpiryTime == null || RefreshTokenExpiryTime < DateTime.UtcNow; + } } diff --git a/src/GuruPR.Domain/Interfaces/Markers/IHasEntityId.cs b/src/GuruPR.Domain/Interfaces/Markers/IHasEntityId.cs new file mode 100644 index 0000000..7a01b04 --- /dev/null +++ b/src/GuruPR.Domain/Interfaces/Markers/IHasEntityId.cs @@ -0,0 +1,6 @@ +namespace GuruPR.Domain.Interfaces.Markers; + +public interface IHasEntityId +{ + string Id { get; } +} \ No newline at end of file diff --git a/src/GuruPR.Domain/Interfaces/Markers/IOwnedEntity.cs b/src/GuruPR.Domain/Interfaces/Markers/IOwnedEntity.cs new file mode 100644 index 0000000..d6a045e --- /dev/null +++ b/src/GuruPR.Domain/Interfaces/Markers/IOwnedEntity.cs @@ -0,0 +1,6 @@ +namespace GuruPR.Domain.Interfaces.Markers; + +public interface IOwnedEntity : IHasEntityId +{ + string UserId { get; set; } +} diff --git a/src/GuruPR.Infrastructure/Exceptions/TokenUpdateFailedException.cs b/src/GuruPR.Infrastructure/Exceptions/TokenUpdateFailedException.cs new file mode 100644 index 0000000..4e68255 --- /dev/null +++ b/src/GuruPR.Infrastructure/Exceptions/TokenUpdateFailedException.cs @@ -0,0 +1,3 @@ +namespace GuruPR.Infrastructure.Exceptions; + +public class TokenUpdateFailedException(string message) : Exception(message); diff --git a/src/GuruPR.Infrastructure/Extensions/ServiceExtensions.cs b/src/GuruPR.Infrastructure/Extensions/ServiceExtensions.cs index a850e03..72352a1 100644 --- a/src/GuruPR.Infrastructure/Extensions/ServiceExtensions.cs +++ b/src/GuruPR.Infrastructure/Extensions/ServiceExtensions.cs @@ -1,13 +1,11 @@ using System.IdentityModel.Tokens.Jwt; -using GuruPR.Application.Interfaces.Application; -using GuruPR.Application.Interfaces.Infrastructure; -using GuruPR.Application.Interfaces.Infrastructure.SemanticKernel.Plugins; -using GuruPR.Application.Services.Account; -using GuruPR.Application.Settings.Authentication; -using GuruPR.Application.Settings.ModelConfiguration.AzureOpenAI; -using GuruPR.Application.Settings.ModelConfiguration.HuggingFace; -using GuruPR.Application.Settings.Security; +using GuruPR.Application.Common.Interfaces.Infrastructure; +using GuruPR.Application.Common.Interfaces.Infrastructure.SemanticKernel.Plugins; +using GuruPR.Application.Common.Settings.Authentication; +using GuruPR.Application.Common.Settings.ModelConfiguration.AzureOpenAI; +using GuruPR.Application.Common.Settings.ModelConfiguration.HuggingFace; +using GuruPR.Application.Common.Settings.Security; using GuruPR.Infrastructure.HttpClients.Spotify; using GuruPR.Infrastructure.Identity.Constants; using GuruPR.Infrastructure.SemanticKernel.Filters; @@ -127,7 +125,6 @@ private static void AddCustomAuthentication(this IServiceCollection services, IC ); services.AddScoped(); - services.AddScoped(); services.AddScoped(); } diff --git a/src/GuruPR.Infrastructure/HttpClients/OAuth/OAuthClientBase.cs b/src/GuruPR.Infrastructure/HttpClients/OAuth/OAuthClientBase.cs index 92eecdc..c72f97c 100644 --- a/src/GuruPR.Infrastructure/HttpClients/OAuth/OAuthClientBase.cs +++ b/src/GuruPR.Infrastructure/HttpClients/OAuth/OAuthClientBase.cs @@ -1,7 +1,7 @@ using System.Net.Http.Headers; using System.Text; -using GuruPR.Domain.Entities.OAuth; +using GuruPR.Domain.Entities.ProviderConnection; namespace GuruPR.Infrastructure.HttpClients.OAuth; diff --git a/src/GuruPR.Infrastructure/SemanticKernel/Filters/FunctionCallTracerFilter.cs b/src/GuruPR.Infrastructure/SemanticKernel/Filters/FunctionCallTracerFilter.cs index 1d954d6..08f1af0 100644 --- a/src/GuruPR.Infrastructure/SemanticKernel/Filters/FunctionCallTracerFilter.cs +++ b/src/GuruPR.Infrastructure/SemanticKernel/Filters/FunctionCallTracerFilter.cs @@ -1,7 +1,7 @@  using System.Text.Json; -using GuruPR.Application.Interfaces.Infrastructure.SemanticKernel.Models; +using GuruPR.Application.Features.Conversations.Models.ToolCalls; using Microsoft.Extensions.Logging; using Microsoft.SemanticKernel; diff --git a/src/GuruPR.Infrastructure/SemanticKernel/Plugins/SpotifyPlugin.cs b/src/GuruPR.Infrastructure/SemanticKernel/Plugins/SpotifyPlugin.cs index 66063dd..cff4081 100644 --- a/src/GuruPR.Infrastructure/SemanticKernel/Plugins/SpotifyPlugin.cs +++ b/src/GuruPR.Infrastructure/SemanticKernel/Plugins/SpotifyPlugin.cs @@ -1,7 +1,7 @@ using System.ComponentModel; -using GuruPR.Application.Interfaces.Infrastructure; -using GuruPR.Application.Interfaces.Infrastructure.SemanticKernel.Plugins; +using GuruPR.Application.Common.Interfaces.Infrastructure; +using GuruPR.Application.Common.Interfaces.Infrastructure.SemanticKernel.Plugins; using Microsoft.SemanticKernel; diff --git a/src/GuruPR.Infrastructure/SemanticKernel/Services/SemanticKernelChatProvider.cs b/src/GuruPR.Infrastructure/SemanticKernel/Services/SemanticKernelChatProvider.cs index 0155509..abb7aa7 100644 --- a/src/GuruPR.Infrastructure/SemanticKernel/Services/SemanticKernelChatProvider.cs +++ b/src/GuruPR.Infrastructure/SemanticKernel/Services/SemanticKernelChatProvider.cs @@ -1,7 +1,8 @@ -using GuruPR.Application.Interfaces.Infrastructure; -using GuruPR.Application.Interfaces.Infrastructure.SemanticKernel.Models; -using GuruPR.Application.Interfaces.Infrastructure.SemanticKernel.Plugins; -using GuruPR.Domain.Entities.Configurations; +using GuruPR.Application.Common.Interfaces.Infrastructure; +using GuruPR.Application.Common.Interfaces.Infrastructure.SemanticKernel.Plugins; +using GuruPR.Application.Features.Conversations.Models.CreateCompletion; +using GuruPR.Application.Features.Conversations.Models.ToolCalls; +using GuruPR.Domain.Entities.Agents.Configurations; using GuruPR.Domain.Entities.Conversation; using GuruPR.Domain.Entities.Message; using GuruPR.Domain.Entities.Tool; @@ -14,7 +15,7 @@ using Microsoft.SemanticKernel.Connectors.AzureOpenAI; using Microsoft.SemanticKernel.Connectors.OpenAI; -using Agent = GuruPR.Domain.Entities.Agent; +using Agent = GuruPR.Domain.Entities.Agents.Agent; namespace GuruPR.Infrastructure.SemanticKernel.Services; @@ -31,7 +32,7 @@ public SemanticKernelChatProvider(ILogger logger, Ke #region Public Methods - public async Task ExecuteAsync(Agent agent, Conversation conversation, IList messages, string userMessage) + public async Task ExecuteAsync(Agent agent, Conversation conversation, IList messages, string userMessage, CancellationToken cancellationToken = default) { var startTime = DateTime.UtcNow; @@ -74,7 +75,7 @@ public async Task ExecuteAsync(Agent agent, Conversation c }; } - public async Task GenerateSummaryAsync(IList messages, string? existingSummary) + public async Task GenerateSummaryAsync(IList messages, string? existingSummary, CancellationToken cancellationToken) { string summaryPrompt; @@ -161,9 +162,9 @@ private ChatHistory PrepareChatHistory(Agent agent, Conversation conversation, I var chatHistory = new ChatHistory(); var memoryConfiguration = agent.MemoryConfiguration; - chatHistory.AddSystemMessage(agent.Instrunctions); + chatHistory.AddSystemMessage(agent.Instructions); - if (!string.IsNullOrEmpty(conversation.Metadata.Summary)) + if (conversation.Metadata != null && !string.IsNullOrEmpty(conversation.Metadata.Summary)) { chatHistory.AddSystemMessage($"Previous conversation summary: {conversation.Metadata.Summary}"); } @@ -204,7 +205,7 @@ private async Task ExecuteWithKernelAsync(Kernel kernel, { Kernel = kernel, Name = safeAgentName, - Instructions = agent.Instrunctions, + Instructions = agent.Instructions, Arguments = new KernelArguments(executionSettings) }; var agentResponse = chatCompletionAgent.InvokeAsync(chatHistory); diff --git a/src/GuruPR.Infrastructure/Services/Authentication/ExternalAuthService.cs b/src/GuruPR.Infrastructure/Services/Authentication/ExternalAuthService.cs index c600994..065783f 100644 --- a/src/GuruPR.Infrastructure/Services/Authentication/ExternalAuthService.cs +++ b/src/GuruPR.Infrastructure/Services/Authentication/ExternalAuthService.cs @@ -1,5 +1,5 @@ -using GuruPR.Application.Interfaces.Application; -using GuruPR.Application.Interfaces.Infrastructure; +using GuruPR.Application.Common.Interfaces.Application; +using GuruPR.Application.Common.Interfaces.Infrastructure; using GuruPR.Domain.Entities; using GuruPR.Domain.Enums; using GuruPR.Domain.Extensions.Authentication; @@ -17,29 +17,29 @@ namespace GuruPR.Infrastructure.Services.Authentication; public class ExternalAuthService : IExternalAuthService { private readonly ILogger _logger; - private readonly IAccountService _accountService; - private readonly IUrlValidator _urlValidator; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IExternalUserProvisioningService _externalUserProvisioningService; private readonly LinkGenerator _linkGenerator; private readonly SignInManager _signInManager; public ExternalAuthService(ILogger logger, - IAccountService accountService, - IUrlValidator urlValidator, + IHttpContextAccessor httpContextAccessor, + IExternalUserProvisioningService externalUserProvisioningService, LinkGenerator linkGenerator, SignInManager signInManager) { _logger = logger; - _accountService = accountService; - _urlValidator = urlValidator; + _httpContextAccessor = httpContextAccessor; + _externalUserProvisioningService = externalUserProvisioningService; _linkGenerator = linkGenerator; _signInManager = signInManager; } - public Task InitiateGoogleLoginAsync(string? returnUrl, HttpContext httpContext) + public Task InitiateGoogleLoginAsync(string? returnUrl) { try { - _urlValidator.ValidateReturnUrl(returnUrl); + var httpContext = _httpContextAccessor.HttpContext ?? throw new InvalidOperationException("Http context is missing."); var callbackUrl = _linkGenerator.GetUriByName(httpContext, "GoogleLoginCallback", new { returnUrl }); if (string.IsNullOrEmpty(callbackUrl)) @@ -62,11 +62,11 @@ public Task InitiateGoogleLoginAsync(string? returnUrl, HttpCon } } - public async Task HandleGoogleCallbackAsync(string returnUrl, HttpContext httpContext) + public async Task HandleGoogleCallbackAsync(string returnUrl) { try { - _urlValidator.ValidateReturnUrl(returnUrl); + var httpContext = _httpContextAccessor.HttpContext ?? throw new InvalidOperationException("Http context is missing."); var authResult = await httpContext.AuthenticateAsync(GoogleDefaults.AuthenticationScheme); if (!authResult.Succeeded) @@ -84,7 +84,7 @@ public async Task HandleGoogleCallbackAsync(string returnUrl, HttpContex } var provider = ExternalProvider.Google.ToName(); - await _accountService.LoginWithExternalProviderAsync(authResult.Principal, provider); + await _externalUserProvisioningService.LoginWithExternalProviderAsync(authResult.Principal, provider); return returnUrl; } @@ -96,7 +96,7 @@ public async Task HandleGoogleCallbackAsync(string returnUrl, HttpContex { _logger.LogError(ex, "Error handling Google login callback"); - throw new InvalidOperationException("Failed to process Google login callback", ex); + throw; } } } diff --git a/src/GuruPR.Infrastructure/Services/Authentication/JwtTokenService.cs b/src/GuruPR.Infrastructure/Services/Authentication/JwtTokenService.cs index 60b9abf..e299a41 100644 --- a/src/GuruPR.Infrastructure/Services/Authentication/JwtTokenService.cs +++ b/src/GuruPR.Infrastructure/Services/Authentication/JwtTokenService.cs @@ -3,14 +3,16 @@ using System.Security.Cryptography; using System.Text; +using GuruPR.Application.Common.Interfaces.Infrastructure; +using GuruPR.Application.Common.Settings.Authentication; using GuruPR.Application.Dtos.Jwt; -using GuruPR.Application.Interfaces.Infrastructure; -using GuruPR.Application.Settings.Security; using GuruPR.Domain.Entities; +using GuruPR.Infrastructure.Exceptions; using GuruPR.Infrastructure.Identity.Constants; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; @@ -18,79 +20,181 @@ namespace GuruPR.Infrastructure.Services.Authentication; public class JwtTokenService : ITokenService { + private readonly ILogger _logger; private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IHasher _hasher; private readonly UserManager _userManager; private readonly JwtSettings _jwtSettings; - - public JwtTokenService(IHttpContextAccessor httpContextAccessor, UserManager userManager, IOptions jwtSettings) + private readonly RefreshTokenSettings _refreshTokenSettings; + + public JwtTokenService(ILogger logger, + IHttpContextAccessor httpContextAccessor, + IHasher hasher, + UserManager userManager, + IOptions jwtSettings, + IOptions refreshTokenSettings) { + _logger = logger; _httpContextAccessor = httpContextAccessor; + _hasher = hasher; _userManager = userManager; _jwtSettings = jwtSettings.Value; + _refreshTokenSettings = refreshTokenSettings.Value; } - public async Task GenerateTokenAsync(User user) + #region Public Methods + + public async Task IssueNewTokenPairAsync(User user) { - var userRoles = await _userManager.GetRolesAsync(user); - var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSettings.SecretKey)); - var signingCredentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256); - var claims = new List + ArgumentNullException.ThrowIfNull(user); + + var jwtTokenResult = await GenerateAccessTokenAsync(user); + var refreshToken = GenerateRefreshToken(); + var refreshTokenExpiry = DateTime.UtcNow.AddDays(_refreshTokenSettings.ExpirationTimeInDays); + + user.UpdateRefreshToken(_hasher.Hash(refreshToken), refreshTokenExpiry); + + var updateResult = await _userManager.UpdateAsync(user); + if (!updateResult.Succeeded) { - new Claim(JwtClaimTypes.Subject, user.Id.ToString()), - new Claim(JwtClaimTypes.JwtId, Guid.NewGuid().ToString()), - new Claim(JwtClaimTypes.Email, user.Email ?? string.Empty), + var errors = string.Join(", ", updateResult.Errors.Select(e => e.Description)); + _logger.LogError("Failed to update refresh token for user {UserId}. Errors: {Errors}", user.Id, errors); - new Claim(JwtClaimTypes.Name, user.ToString()) - }; - claims.AddRange(userRoles.Select(role => new Claim(JwtClaimTypes.Role, role))); + throw new TokenUpdateFailedException("Failed to update refresh token for the user."); + } + + WriteAccessTokenCookie(jwtTokenResult.Token, jwtTokenResult.ExpiresAtUtc); + WriteRefreshTokenCookie(refreshToken, refreshTokenExpiry); + } + + public async Task RenewAccessTokenAsync(User user) + { + ArgumentNullException.ThrowIfNull(user); + + var jwtTokenResult = await GenerateAccessTokenAsync(user); - JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear(); + WriteAccessTokenCookie(jwtTokenResult.Token, jwtTokenResult.ExpiresAtUtc); + } + + public async Task RevokeTokensAsync(User user) + { + ArgumentNullException.ThrowIfNull(user); + + user.ClearRefreshToken(); + var updateResult = await _userManager.UpdateAsync(user); + if (!updateResult.Succeeded) + { + var errors = string.Join(", ", updateResult.Errors.Select(error => error.Description)); + _logger.LogError("Failed to revoke tokens for user {UserId}. Errors: {Errors}", user.Id, errors); + + throw new TokenUpdateFailedException("Failed to revoke tokens for the user."); + } - var expires = DateTime.UtcNow.AddMinutes(_jwtSettings.ExpirationTimeInMinutes); + ClearAuthenticationCookies(); + } + + #endregion + + #region Private Methods - Token Generation + + private async Task GenerateAccessTokenAsync(User user) + { + var userRoles = await _userManager.GetRolesAsync(user); + var claims = BuildClaims(user, userRoles); + var signingCredentials = CreateSigningCredentials(); + var expiresAt = DateTime.UtcNow.AddMinutes(_jwtSettings.ExpirationTimeInMinutes); var token = new JwtSecurityToken(issuer: _jwtSettings.Issuer, audience: _jwtSettings.Audience, claims: claims, notBefore: DateTime.UtcNow, - expires: expires, + expires: expiresAt, signingCredentials: signingCredentials); var jwtToken = new JwtSecurityTokenHandler().WriteToken(token); - var jwtTokenResult = new JwtTokenResult + + return new JwtTokenResult { Token = jwtToken, - ExpiresAtUtc = expires + ExpiresAtUtc = expiresAt }; + } - return jwtTokenResult; + private List BuildClaims(User user, IList roles) + { + var claims = new List + { + new(JwtClaimTypes.Subject, user.Id.ToString()), + new(JwtClaimTypes.JwtId, Guid.NewGuid().ToString()), + new(JwtClaimTypes.Email, user.Email ?? string.Empty), + new(JwtClaimTypes.Name, user.ToString()) + }; + + claims.AddRange(roles.Select(role => new Claim(JwtClaimTypes.Role, role))); + + return claims; } - public string GenerateRefreshToken() + private SigningCredentials CreateSigningCredentials() { - var randomNumber = new byte[64]; - using var randomNumberGenerator = RandomNumberGenerator.Create(); + var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSettings.SecretKey)); + + return new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256); + } - randomNumberGenerator.GetBytes(randomNumber); + private static string GenerateRefreshToken() + { + var randomBytes = RandomNumberGenerator.GetBytes(64); - return Convert.ToBase64String(randomNumber); + return Convert.ToBase64String(randomBytes); } - public void WriteAuthTokenAsHttpOnlyCookie(string cookieName, string token, DateTime expiration) + #endregion + + #region Private Methods - Cookie Management + + private void WriteAccessTokenCookie(string token, DateTime expiration) { - var httpContext = _httpContextAccessor.HttpContext; + var httpContext = GetHttpContext(); + var cookieOptions = CreateSecureCookieOptions(expiration); - if (httpContext == null) - { + httpContext.Response.Cookies.Append("AccessToken", token, cookieOptions); + } + + private void WriteRefreshTokenCookie(string token, DateTime expiration) + { + var httpContext = GetHttpContext(); + var cookieOptions = CreateSecureCookieOptions(expiration); + + httpContext.Response.Cookies.Append("RefreshToken", token, cookieOptions); + } + + private void ClearAuthenticationCookies() + { + var httpContext = GetHttpContext(); + + httpContext.Response.Cookies.Delete("AccessToken"); + httpContext.Response.Cookies.Delete("RefreshToken"); + } + + private HttpContext GetHttpContext() + { + return _httpContextAccessor.HttpContext ?? throw new InvalidOperationException("HttpContext is not available. This method must be called within an HTTP request context."); - } + } - var cookieOptions = new CookieOptions + private CookieOptions CreateSecureCookieOptions(DateTime expiration) + { + return new CookieOptions { - Secure = true, HttpOnly = true, - Expires = expiration, + Secure = true, + SameSite = SameSiteMode.Strict, // Use Strict for better security, change to None only if needed for CORS IsEssential = true, - SameSite = SameSiteMode.None + Expires = expiration, + // Path = "/" + // Domain = _jwtSettings.CookieDomain // Configure in production }; - httpContext.Response.Cookies.Append(cookieName, token, cookieOptions); } + + #endregion } diff --git a/src/GuruPR.Infrastructure/Services/Email/GmailSender.cs b/src/GuruPR.Infrastructure/Services/Email/GmailSender.cs index 394babf..e16f920 100644 --- a/src/GuruPR.Infrastructure/Services/Email/GmailSender.cs +++ b/src/GuruPR.Infrastructure/Services/Email/GmailSender.cs @@ -1,4 +1,4 @@ -using GuruPR.Application.Settings.Email; +using GuruPR.Application.Common.Settings.Email; using MailKit.Net.Smtp; using MailKit.Security; @@ -9,6 +9,7 @@ using MimeKit; namespace GuruPR.Infrastructure.Services.Email; + public class GmailSender : IEmailSender { private GmailingAppSettings _gmailingSettings; diff --git a/src/GuruPR.Infrastructure/Services/Security/HmacTokenHasher.cs b/src/GuruPR.Infrastructure/Services/Security/HmacTokenHasher.cs index 1d48048..6555050 100644 --- a/src/GuruPR.Infrastructure/Services/Security/HmacTokenHasher.cs +++ b/src/GuruPR.Infrastructure/Services/Security/HmacTokenHasher.cs @@ -1,8 +1,8 @@ using System.Security.Cryptography; using System.Text; -using GuruPR.Application.Interfaces.Infrastructure; -using GuruPR.Application.Settings.Security; +using GuruPR.Application.Common.Interfaces.Infrastructure; +using GuruPR.Application.Common.Settings.Security; using Microsoft.Extensions.Options; diff --git a/src/GuruPR.Infrastructure/Services/Security/TokenEncryptionService.cs b/src/GuruPR.Infrastructure/Services/Security/TokenEncryptionService.cs index eab40f3..c71d1c2 100644 --- a/src/GuruPR.Infrastructure/Services/Security/TokenEncryptionService.cs +++ b/src/GuruPR.Infrastructure/Services/Security/TokenEncryptionService.cs @@ -1,8 +1,8 @@ using System.Security.Cryptography; using System.Text; -using GuruPR.Application.Interfaces.Infrastructure; -using GuruPR.Application.Settings.Security; +using GuruPR.Application.Common.Interfaces.Infrastructure; +using GuruPR.Application.Common.Settings.Security; using Microsoft.Extensions.Options; diff --git a/src/GuruPR.Infrastructure/Services/ThirdParties/SpotifyService.cs b/src/GuruPR.Infrastructure/Services/ThirdParties/SpotifyService.cs index 7e3b68d..85e7cd1 100644 --- a/src/GuruPR.Infrastructure/Services/ThirdParties/SpotifyService.cs +++ b/src/GuruPR.Infrastructure/Services/ThirdParties/SpotifyService.cs @@ -1,10 +1,7 @@ using System.Text; using System.Text.Json; -using GuruPR.Application.Dtos.OAuth.ProviderConnection; -using GuruPR.Application.Interfaces.Application; -using GuruPR.Application.Interfaces.Infrastructure; -using GuruPR.Domain.Entities.Enums; +using GuruPR.Application.Common.Interfaces.Infrastructure; using GuruPR.Infrastructure.HttpClients.Spotify; using Microsoft.Extensions.Logging; @@ -16,20 +13,14 @@ public class SpotifyService : ISpotifyService private readonly ILogger _logger; private readonly SpotifyClient _spotifyClient; private readonly SpotifyOAuthClient _spotifyOAuthClient; - private readonly IProviderService _providerService; - private readonly IProviderConnectionService _providerConnectionService; public SpotifyService(ILogger logger, SpotifyClient spotifyClient, - SpotifyOAuthClient spotifyOAuthClient, - IProviderService providerService, - IProviderConnectionService providerConnectionService) + SpotifyOAuthClient spotifyOAuthClient) { _logger = logger; _spotifyClient = spotifyClient; _spotifyOAuthClient = spotifyOAuthClient; - _providerService = providerService; - _providerConnectionService = providerConnectionService; } #region Public Methods @@ -37,35 +28,37 @@ public SpotifyService(ILogger logger, public async Task GetSpotifyAccessTokenAsync(string userId, string scope) { //TODO: Integrate userId in the query to fetch the correct connection - var provider = await _providerService.GetProviderByTypeAsync(OAuthProviderType.Spotify); - var providerConnection = await _providerConnectionService.GetProviderConnectionByProviderTypeAndScopeAsync(OAuthProviderType.Spotify, scope); - - if (providerConnection == null) - { - throw new InvalidOperationException("No Spotify connection found for the user."); - } - - if (providerConnection.Scopes == null || !providerConnection.Scopes.Contains(scope)) - { - throw new InvalidOperationException($"The existing connection does not have the required scope: {scope}"); - } - - if (providerConnection.ShouldRefreshToken()) - { - var tokenResponse = await _spotifyOAuthClient.RefreshTokenAsync(provider.TokenUrl, providerConnection); - var updatedConnection = new UpdateProviderConnectionRequest - { - AccessToken = tokenResponse.AccessToken, - RefreshToken = tokenResponse.RefreshToken ?? providerConnection.RefreshToken, - AccessExpiresAt = DateTime.UtcNow.AddSeconds(tokenResponse.ExpiresIn) - }; - - providerConnection = await _providerConnectionService.UpdateProviderConnectionByProviderTypeAsync(OAuthProviderType.Spotify, - providerConnection.Id, - updatedConnection); - } - - return providerConnection.AccessToken; + //var provider = await _providerService.GetProviderByTypeAsync(OAuthProviderType.Spotify); + //var providerConnection = await _providerConnectionService.GetProviderConnectionByProviderTypeAndScopeAsync(OAuthProviderType.Spotify, scope); + + //if (providerConnection == null) + //{ + // throw new InvalidOperationException("No Spotify connection found for the user."); + //} + + //if (providerConnection.Scopes == null || !providerConnection.Scopes.Contains(scope)) + //{ + // throw new InvalidOperationException($"The existing connection does not have the required scope: {scope}"); + //} + + //if (providerConnection.ShouldRefreshToken()) + //{ + // var tokenResponse = await _spotifyOAuthClient.RefreshTokenAsync(provider.TokenUrl, providerConnection); + // var updatedConnection = new UpdateProviderConnectionRequest + // { + // AccessToken = tokenResponse.AccessToken, + // RefreshToken = tokenResponse.RefreshToken ?? providerConnection.RefreshToken, + // AccessExpiresAt = DateTime.UtcNow.AddSeconds(tokenResponse.ExpiresIn) + // }; + + // providerConnection = await _providerConnectionService.UpdateProviderConnectionByProviderTypeAsync(OAuthProviderType.Spotify, + // providerConnection.Id, + // updatedConnection); + //} + + //return providerConnection.AccessToken; + + throw new NotImplementedException("User-specific Spotify access token retrieval is not implemented yet."); } public async Task GetLikedTracksAsync(string token, int numberOfTracks) @@ -127,11 +120,13 @@ private string ParseTracksFromJson(string json) catch (JsonException jsonException) { _logger.LogError(jsonException, "Error parsing Spotify API response: {Message}", jsonException.Message); + return $"Error parsing Spotify API response."; } catch (Exception exception) { _logger.LogError(exception, "Unexpected error processing Spotify API response: {Message}", exception.Message); + return "An unexpected error occurred while processing the response."; } diff --git a/src/GuruPR.Persistence/Contexts/GuruDBContext.cs b/src/GuruPR.Persistence/Contexts/GuruDBContext.cs index b2f0d91..2e05506 100644 --- a/src/GuruPR.Persistence/Contexts/GuruDBContext.cs +++ b/src/GuruPR.Persistence/Contexts/GuruDBContext.cs @@ -1,8 +1,8 @@ -using GuruPR.Application.Interfaces.Infrastructure; -using GuruPR.Domain.Entities; +using GuruPR.Application.Common.Interfaces.Infrastructure; +using GuruPR.Domain.Entities.Agents; using GuruPR.Domain.Entities.Conversation; using GuruPR.Domain.Entities.Message; -using GuruPR.Domain.Entities.OAuth; +using GuruPR.Domain.Entities.Provider; using GuruPR.Domain.Entities.Tool; using GuruPR.Persistence.Extensions.ContextConfiguration; @@ -43,6 +43,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.ApplyConfiguration(new AgentConfiguration()); modelBuilder.ApplyConfiguration(new MessageConfiguration()); modelBuilder.ApplyConfiguration(new ConversationConfiguration()); - modelBuilder.ApplyConfiguration(new ProviderConfiguration(_tokenEncryptionService)); + modelBuilder.ApplyConfiguration(new ProviderConfiguration()); + modelBuilder.ApplyConfiguration(new ProviderConnectionConfiguration(_tokenEncryptionService)); } } diff --git a/src/GuruPR.Persistence/Extensions/ContextConfiguration/AgentConfiguration.cs b/src/GuruPR.Persistence/Extensions/ContextConfiguration/AgentConfiguration.cs index 5eb967c..eae26c3 100644 --- a/src/GuruPR.Persistence/Extensions/ContextConfiguration/AgentConfiguration.cs +++ b/src/GuruPR.Persistence/Extensions/ContextConfiguration/AgentConfiguration.cs @@ -1,4 +1,4 @@ -using GuruPR.Domain.Entities; +using GuruPR.Domain.Entities.Agents; using GuruPR.Persistence.Helpers; using Microsoft.EntityFrameworkCore; diff --git a/src/GuruPR.Persistence/Extensions/ContextConfiguration/ProviderConfiguration.cs b/src/GuruPR.Persistence/Extensions/ContextConfiguration/ProviderConfiguration.cs index 4b8d624..93dd905 100644 --- a/src/GuruPR.Persistence/Extensions/ContextConfiguration/ProviderConfiguration.cs +++ b/src/GuruPR.Persistence/Extensions/ContextConfiguration/ProviderConfiguration.cs @@ -1,5 +1,4 @@ -using GuruPR.Application.Interfaces.Infrastructure; -using GuruPR.Domain.Entities.OAuth; +using GuruPR.Domain.Entities.Provider; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; @@ -8,11 +7,8 @@ namespace GuruPR.Persistence.Extensions.ContextConfiguration; public class ProviderConfiguration : IEntityTypeConfiguration { - private readonly ITokenEncryptionService _tokenEncryptionService; - - public ProviderConfiguration(ITokenEncryptionService tokenEncryptionService) + public ProviderConfiguration() { - _tokenEncryptionService = tokenEncryptionService; } public void Configure(EntityTypeBuilder builder) @@ -23,28 +19,5 @@ public void Configure(EntityTypeBuilder builder) builder.Property(provider => provider.Id) .IsRequired(); - - builder.OwnsMany(provider => provider.ProviderConnections, navigationBuilder => - { - navigationBuilder.Property(navigationBuilder => navigationBuilder.ClientSecret) - .HasConversion( - plainText => _tokenEncryptionService.Encrypt(plainText), - cipherText => _tokenEncryptionService.Decrypt(cipherText)); - - navigationBuilder.Property(providerConnection => providerConnection.ClientSecret) - .HasConversion( - plainText => _tokenEncryptionService.Encrypt(plainText), - cipherText => _tokenEncryptionService.Decrypt(cipherText)); - - navigationBuilder.Property(providerConnection => providerConnection.AccessToken) - .HasConversion( - plainText => _tokenEncryptionService.Encrypt(plainText), - cipherText => _tokenEncryptionService.Decrypt(cipherText)); - - navigationBuilder.Property(providerConnection => providerConnection.RefreshToken) - .HasConversion( - plainText => _tokenEncryptionService.Encrypt(plainText), - cipherText => _tokenEncryptionService.Decrypt(cipherText)); - }); } } diff --git a/src/GuruPR.Persistence/Extensions/ContextConfiguration/ProviderConnectionConfiguration.cs b/src/GuruPR.Persistence/Extensions/ContextConfiguration/ProviderConnectionConfiguration.cs new file mode 100644 index 0000000..6bce441 --- /dev/null +++ b/src/GuruPR.Persistence/Extensions/ContextConfiguration/ProviderConnectionConfiguration.cs @@ -0,0 +1,45 @@ +using GuruPR.Application.Common.Interfaces.Infrastructure; +using GuruPR.Domain.Entities.ProviderConnection; + +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace GuruPR.Persistence.Extensions.ContextConfiguration; + +public class ProviderConnectionConfiguration : IEntityTypeConfiguration +{ + ITokenEncryptionService _tokenEncryptionService; + + public ProviderConnectionConfiguration(ITokenEncryptionService tokenEncryptionService) + { + _tokenEncryptionService = tokenEncryptionService; + } + + public void Configure(EntityTypeBuilder builder) + { + builder.ToContainer("provider_connections") + .HasPartitionKey(providerConnection => providerConnection.Id) + .HasNoDiscriminator(); + + builder.Property(providerConnection => providerConnection.Id) + .IsRequired(); + + builder.Property(providerConnection => providerConnection.ClientSecret) + .HasConversion( + plainText => _tokenEncryptionService.Encrypt(plainText), + cipherText => _tokenEncryptionService.Decrypt(cipherText) + ); + + builder.Property(providerConnection => providerConnection.AccessToken) + .HasConversion( + plainText => _tokenEncryptionService.Encrypt(plainText), + cipherText => _tokenEncryptionService.Decrypt(cipherText) + ); + + builder.Property(providerConnection => providerConnection.RefreshToken) + .HasConversion( + plainText => _tokenEncryptionService.Encrypt(plainText), + cipherText => _tokenEncryptionService.Decrypt(cipherText) + ); + } +} diff --git a/src/GuruPR.Persistence/Extensions/ServiceExtensions.cs b/src/GuruPR.Persistence/Extensions/ServiceExtensions.cs index 67d0a43..bdca4e4 100644 --- a/src/GuruPR.Persistence/Extensions/ServiceExtensions.cs +++ b/src/GuruPR.Persistence/Extensions/ServiceExtensions.cs @@ -1,8 +1,8 @@ -using GuruPR.Application.Interfaces.Persistence; -using GuruPR.Application.Services.Account.IdentityValidators; -using GuruPR.Application.Settings.Database; +using GuruPR.Application.Common.Interfaces.Persistence; +using GuruPR.Application.Common.Settings.Database; using GuruPR.Domain.Entities; using GuruPR.Persistence.Contexts; +using GuruPR.Persistence.Identity.IdentityValidators; using GuruPR.Persistence.Repositories; using Microsoft.AspNetCore.Identity; @@ -81,6 +81,8 @@ private static void AddRepositories(this IServiceCollection services) services.AddScoped(); + services.AddScoped(typeof(IOwnedGenericRepository<>), typeof(OwnedGenericRepository<>)); + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/GuruPR.Persistence/Identity/DbInitializer.cs b/src/GuruPR.Persistence/Identity/DbInitializer.cs index 53cc1a7..bea6423 100644 --- a/src/GuruPR.Persistence/Identity/DbInitializer.cs +++ b/src/GuruPR.Persistence/Identity/DbInitializer.cs @@ -1,6 +1,6 @@ -using GuruPR.Application.Exceptions; +using GuruPR.Application.Common.Exceptions; +using GuruPR.Application.Common.Settings.Database; using GuruPR.Application.Exceptions.Account; -using GuruPR.Application.Settings.Database; using GuruPR.Domain.Entities; using GuruPR.Domain.Enums; using GuruPR.Domain.Extensions.User; diff --git a/src/GuruPR.Application/Services/Account/IdentityValidators/StrictEmailDomainValidator.cs b/src/GuruPR.Persistence/Identity/IdentityValidators/StrictEmailDomainValidator.cs similarity index 94% rename from src/GuruPR.Application/Services/Account/IdentityValidators/StrictEmailDomainValidator.cs rename to src/GuruPR.Persistence/Identity/IdentityValidators/StrictEmailDomainValidator.cs index 973516a..cf522f7 100644 --- a/src/GuruPR.Application/Services/Account/IdentityValidators/StrictEmailDomainValidator.cs +++ b/src/GuruPR.Persistence/Identity/IdentityValidators/StrictEmailDomainValidator.cs @@ -1,10 +1,10 @@ -using GuruPR.Application.Settings.Security; +using GuruPR.Application.Common.Settings.Security; using GuruPR.Domain.Entities; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Options; -namespace GuruPR.Application.Services.Account.IdentityValidators; +namespace GuruPR.Persistence.Identity.IdentityValidators; public class StrictEmailDomainValidator : IUserValidator { diff --git a/src/GuruPR.Application/Services/Account/IdentityValidators/UserProfileValidator.cs b/src/GuruPR.Persistence/Identity/IdentityValidators/UserProfileValidator.cs similarity index 98% rename from src/GuruPR.Application/Services/Account/IdentityValidators/UserProfileValidator.cs rename to src/GuruPR.Persistence/Identity/IdentityValidators/UserProfileValidator.cs index 7e35339..aa5e8dd 100644 --- a/src/GuruPR.Application/Services/Account/IdentityValidators/UserProfileValidator.cs +++ b/src/GuruPR.Persistence/Identity/IdentityValidators/UserProfileValidator.cs @@ -5,7 +5,7 @@ using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Logging; -namespace GuruPR.Application.Services.Account.IdentityValidators; +namespace GuruPR.Persistence.Identity.IdentityValidators; public class UserProfileValidator : IUserValidator { diff --git a/src/GuruPR.Persistence/Repositories/AgentRepository.cs b/src/GuruPR.Persistence/Repositories/AgentRepository.cs index 0f144c1..513c57f 100644 --- a/src/GuruPR.Persistence/Repositories/AgentRepository.cs +++ b/src/GuruPR.Persistence/Repositories/AgentRepository.cs @@ -1,12 +1,12 @@ -using GuruPR.Application.Interfaces.Persistence; -using GuruPR.Domain.Entities; +using GuruPR.Application.Common.Interfaces.Persistence; +using GuruPR.Domain.Entities.Agents; using GuruPR.Persistence.Contexts; using Microsoft.EntityFrameworkCore; namespace GuruPR.Persistence.Repositories; -public class AgentRepository : GenericRepository, IAgentRepository +public class AgentRepository : OwnedGenericRepository, IAgentRepository { public AgentRepository(GuruDbContext dbContext) : base(dbContext) { @@ -14,7 +14,7 @@ public AgentRepository(GuruDbContext dbContext) : base(dbContext) public async Task> GetAllAgentsAsync(string userId) { - return await _dbSet.Where(agent => agent.CreatedByUserId == userId) + return await _dbSet.Where(agent => agent.UserId == userId) .ToListAsync(); } } diff --git a/src/GuruPR.Persistence/Repositories/ConversationRepository.cs b/src/GuruPR.Persistence/Repositories/ConversationRepository.cs index a814d64..4db4384 100644 --- a/src/GuruPR.Persistence/Repositories/ConversationRepository.cs +++ b/src/GuruPR.Persistence/Repositories/ConversationRepository.cs @@ -1,4 +1,4 @@ -using GuruPR.Application.Interfaces.Persistence; +using GuruPR.Application.Common.Interfaces.Persistence; using GuruPR.Domain.Entities.Conversation; using GuruPR.Persistence.Contexts; @@ -6,7 +6,7 @@ namespace GuruPR.Persistence.Repositories; -public class ConversationRepository : GenericRepository, IConversationRepository +public class ConversationRepository : OwnedGenericRepository, IConversationRepository { public ConversationRepository(GuruDbContext dbContext) : base(dbContext) { diff --git a/src/GuruPR.Persistence/Repositories/GenericRepository.cs b/src/GuruPR.Persistence/Repositories/GenericRepository.cs index 99b339f..6ce68a6 100644 --- a/src/GuruPR.Persistence/Repositories/GenericRepository.cs +++ b/src/GuruPR.Persistence/Repositories/GenericRepository.cs @@ -1,4 +1,6 @@ -using GuruPR.Application.Interfaces.Persistence; +using System.Linq.Expressions; + +using GuruPR.Application.Common.Interfaces.Persistence; using Microsoft.EntityFrameworkCore; @@ -15,19 +17,32 @@ public GenericRepository(DbContext context) _dbSet = _context.Set(); } - public async Task> GetAllAsync() + public IQueryable AsQueryable() + { + return _dbSet.AsQueryable(); + } + + public async Task> GetAllAsync(Expression>? predicate = null, CancellationToken cancellationToken = default) { - return await _dbSet.ToListAsync(); + var query = _dbSet.AsNoTracking() + .AsQueryable(); + + if (predicate != null) + { + query = query.Where(predicate); + } + + return await query.ToListAsync(cancellationToken); } - public async Task GetByIdAsync(string id) + public async Task GetByIdAsync(string id, CancellationToken cancellationToken = default) { - return await _dbSet.FindAsync(id); + return await _dbSet.FindAsync([id], cancellationToken); ; } - public async Task AddAsync(T entity) + public async Task AddAsync(T entity, CancellationToken cancellationToken = default) { - var result = await _dbSet.AddAsync(entity); + var result = await _dbSet.AddAsync(entity, cancellationToken); return result.Entity; } diff --git a/src/GuruPR.Persistence/Repositories/MessageRepository.cs b/src/GuruPR.Persistence/Repositories/MessageRepository.cs index 1a0231e..82f143f 100644 --- a/src/GuruPR.Persistence/Repositories/MessageRepository.cs +++ b/src/GuruPR.Persistence/Repositories/MessageRepository.cs @@ -1,4 +1,4 @@ -using GuruPR.Application.Interfaces.Persistence; +using GuruPR.Application.Common.Interfaces.Persistence; using GuruPR.Domain.Entities.Message; using GuruPR.Persistence.Contexts; @@ -12,15 +12,15 @@ public MessageRepository(GuruDbContext dbContext) : base(dbContext) { } - public async Task> GetMessagesAsync(string conversationId, int? lastMessages = null) + public async Task> GetMessagesAsync(string conversationId, int? lastMessages = null, CancellationToken cancellationToken = default) { var baseQuery = _dbSet.Where(message => message.ConversationId == conversationId); if (lastMessages.HasValue && lastMessages.Value > 0) { - var recent = await baseQuery.OrderByDescending(m => m.CreatedAt) + var recent = await baseQuery.OrderByDescending(message => message.CreatedAt) .Take(lastMessages.Value) - .ToListAsync(); + .ToListAsync(cancellationToken); recent.Reverse(); @@ -28,12 +28,12 @@ public async Task> GetMessagesAsync(string conversationId, int? la } return await baseQuery.OrderBy(m => m.CreatedAt) - .ToListAsync(); + .ToListAsync(cancellationToken); } - public async Task DeleteConversationMessagesAsync(string conversationId) + public async Task DeleteConversationMessagesAsync(string conversationId, CancellationToken cancellationToken = default) { - var messages = await _dbSet.Where(message => message.ConversationId == conversationId) - .ExecuteDeleteAsync(); + await _dbSet.Where(message => message.ConversationId == conversationId) + .ForEachAsync(message => _dbSet.Remove(message), cancellationToken); } } diff --git a/src/GuruPR.Persistence/Repositories/OwnedGenericRepository.cs b/src/GuruPR.Persistence/Repositories/OwnedGenericRepository.cs new file mode 100644 index 0000000..7e2ecd1 --- /dev/null +++ b/src/GuruPR.Persistence/Repositories/OwnedGenericRepository.cs @@ -0,0 +1,21 @@ +using GuruPR.Application.Common.Interfaces.Persistence; +using GuruPR.Domain.Interfaces.Markers; +using GuruPR.Persistence.Contexts; + +using Microsoft.EntityFrameworkCore; + +namespace GuruPR.Persistence.Repositories; + +public class OwnedGenericRepository : GenericRepository, IOwnedGenericRepository where T : class, IOwnedEntity +{ + public OwnedGenericRepository(GuruDbContext guruDbContext) : base(guruDbContext) + { + } + + public async Task GetByIdAndOwnerAsync(string id, string ownerId, CancellationToken cancellationToken = default) + { + var entity = await _dbSet.FirstOrDefaultAsync(entity => entity.Id == id && entity.UserId == ownerId, cancellationToken); + + return entity; + } +} diff --git a/src/GuruPR.Persistence/Repositories/ProviderConnectionRepository.cs b/src/GuruPR.Persistence/Repositories/ProviderConnectionRepository.cs new file mode 100644 index 0000000..570a927 --- /dev/null +++ b/src/GuruPR.Persistence/Repositories/ProviderConnectionRepository.cs @@ -0,0 +1,20 @@ +using GuruPR.Application.Common.Interfaces.Persistence; +using GuruPR.Domain.Entities.ProviderConnection; +using GuruPR.Persistence.Contexts; + +using Microsoft.EntityFrameworkCore; + +namespace GuruPR.Persistence.Repositories; + +public class ProviderConnectionRepository : GenericRepository, IProviderConnectionRepository +{ + public ProviderConnectionRepository(GuruDbContext guruDBContext) : base(guruDBContext) + { + } + + public async Task DeleteProviderConnectionsByProviderIdAsync(string providerId, CancellationToken cancellationToken = default) + { + await _dbSet.Where(providerConnection => providerConnection.ProviderId == providerId) + .ExecuteDeleteAsync(cancellationToken); + } +} diff --git a/src/GuruPR.Persistence/Repositories/ProviderRepository.cs b/src/GuruPR.Persistence/Repositories/ProviderRepository.cs index 3aaa66f..910f578 100644 --- a/src/GuruPR.Persistence/Repositories/ProviderRepository.cs +++ b/src/GuruPR.Persistence/Repositories/ProviderRepository.cs @@ -1,6 +1,6 @@ -using GuruPR.Application.Interfaces.Persistence; -using GuruPR.Domain.Entities.Enums; -using GuruPR.Domain.Entities.OAuth; +using GuruPR.Application.Common.Interfaces.Persistence; +using GuruPR.Domain.Entities.Provider; +using GuruPR.Domain.Entities.Provider.Enums; using GuruPR.Persistence.Contexts; using Microsoft.EntityFrameworkCore; diff --git a/src/GuruPR.Persistence/Repositories/ToolRepository.cs b/src/GuruPR.Persistence/Repositories/ToolRepository.cs index b4f8300..a8c7085 100644 --- a/src/GuruPR.Persistence/Repositories/ToolRepository.cs +++ b/src/GuruPR.Persistence/Repositories/ToolRepository.cs @@ -1,4 +1,4 @@ -using GuruPR.Application.Interfaces.Persistence; +using GuruPR.Application.Common.Interfaces.Persistence; using GuruPR.Domain.Entities.Tool; using GuruPR.Persistence.Contexts; diff --git a/src/GuruPR.Persistence/Repositories/UnitOfWork.cs b/src/GuruPR.Persistence/Repositories/UnitOfWork.cs index 8f09668..0c53f5e 100644 --- a/src/GuruPR.Persistence/Repositories/UnitOfWork.cs +++ b/src/GuruPR.Persistence/Repositories/UnitOfWork.cs @@ -1,6 +1,6 @@ using System.Transactions; -using GuruPR.Application.Interfaces.Persistence; +using GuruPR.Application.Common.Interfaces.Persistence; using GuruPR.Persistence.Contexts; using Microsoft.EntityFrameworkCore; @@ -22,6 +22,7 @@ public class UnitOfWork : IUnitOfWork private IMessageRepository? _messageRepository; private IProviderRepository? _providerRepository; private IConversationRepository? _conversationRepository; + private IProviderConnectionRepository? _providerConnectionRepository; private IUserRepository? _userRepository; @@ -36,6 +37,7 @@ public UnitOfWork(GuruDbContext guruDbContext, UserManagementDbContext userManag public IMessageRepository Messages => _messageRepository ??= new MessageRepository(_guruDbContext); public IProviderRepository Providers => _providerRepository ??= new ProviderRepository(_guruDbContext); public IConversationRepository Conversations => _conversationRepository ??= new ConversationRepository(_guruDbContext); + public IProviderConnectionRepository ProviderConnections => _providerConnectionRepository ??= new ProviderConnectionRepository(_guruDbContext); public IUserRepository Users => _userRepository ??= new UserRepository(_userManagementDbContext); diff --git a/src/GuruPR.Persistence/Repositories/UserRepository.cs b/src/GuruPR.Persistence/Repositories/UserRepository.cs index bd0b87c..b0ae56b 100644 --- a/src/GuruPR.Persistence/Repositories/UserRepository.cs +++ b/src/GuruPR.Persistence/Repositories/UserRepository.cs @@ -1,4 +1,4 @@ -using GuruPR.Application.Interfaces.Persistence; +using GuruPR.Application.Common.Interfaces.Persistence; using GuruPR.Domain.Entities; using GuruPR.Persistence.Contexts;