diff --git a/Streetcode/Streetcode.Auth.BLL/DTO/Auth/LoginWithGoogleDTO.cs b/Streetcode/Streetcode.Auth.BLL/DTO/Auth/LoginWithGoogleDTO.cs new file mode 100644 index 0000000..38521e6 --- /dev/null +++ b/Streetcode/Streetcode.Auth.BLL/DTO/Auth/LoginWithGoogleDTO.cs @@ -0,0 +1,9 @@ +namespace Streetcode.Auth.BLL.DTO.Auth +{ + public class LoginWithGoogleDTO + { + public string Email { get; set; } + public string Name { get; set; } + public string Surname { get; set; } + } +} diff --git a/Streetcode/Streetcode.Auth.BLL/Mapping/AuthProfile.cs b/Streetcode/Streetcode.Auth.BLL/Mapping/AuthProfile.cs index 9d15b06..92c8d43 100644 --- a/Streetcode/Streetcode.Auth.BLL/Mapping/AuthProfile.cs +++ b/Streetcode/Streetcode.Auth.BLL/Mapping/AuthProfile.cs @@ -13,6 +13,13 @@ public AuthProfile() .ForMember(dest => dest.UserName, opt => opt.MapFrom(src => src.Email)); CreateMap(); + + CreateMap() + .ForMember(dest => dest.UserName, opt => opt.MapFrom(src => src.Email)) + .ForMember(dest => dest.Email, opt => opt.MapFrom(src => src.Email)) + .ForMember(dest => dest.Name, opt => opt.MapFrom(src => src.Name)) + .ForMember(dest => dest.Surname, opt => opt.MapFrom(src => src.Surname)) + .ForMember(dest => dest.EmailConfirmed, opt => opt.MapFrom(_ => true)); } } } diff --git a/Streetcode/Streetcode.Auth.BLL/MediatR/LoginWithGoogle/LoginWithGoogleCommand.cs b/Streetcode/Streetcode.Auth.BLL/MediatR/LoginWithGoogle/LoginWithGoogleCommand.cs new file mode 100644 index 0000000..e6b9008 --- /dev/null +++ b/Streetcode/Streetcode.Auth.BLL/MediatR/LoginWithGoogle/LoginWithGoogleCommand.cs @@ -0,0 +1,8 @@ +using FluentResults; +using MediatR; +using Streetcode.Auth.BLL.DTO.Auth; + +namespace Streetcode.Auth.BLL.MediatR.LoginWithGoogle +{ + public record LoginWithGoogleCommand(LoginWithGoogleDTO LoginGoogle) : IRequest>; +} diff --git a/Streetcode/Streetcode.Auth.BLL/MediatR/LoginWithGoogle/LoginWithGoogleCommandValidation.cs b/Streetcode/Streetcode.Auth.BLL/MediatR/LoginWithGoogle/LoginWithGoogleCommandValidation.cs new file mode 100644 index 0000000..41d0eed --- /dev/null +++ b/Streetcode/Streetcode.Auth.BLL/MediatR/LoginWithGoogle/LoginWithGoogleCommandValidation.cs @@ -0,0 +1,14 @@ +using FluentValidation; +using Streetcode.Auth.BLL.MediatR.Login; + +namespace Streetcode.Auth.BLL.MediatR.LoginWithGoogle +{ + public class LoginWithGoogleCommandValidation : AbstractValidator + { + public LoginWithGoogleCommandValidation() + { + RuleFor(x => x.LoginGoogle) + .SetValidator(new LoginWithGoogleDTOValidator()); + } + } +} diff --git a/Streetcode/Streetcode.Auth.BLL/MediatR/LoginWithGoogle/LoginWithGoogleDTOValidator.cs b/Streetcode/Streetcode.Auth.BLL/MediatR/LoginWithGoogle/LoginWithGoogleDTOValidator.cs new file mode 100644 index 0000000..4b52702 --- /dev/null +++ b/Streetcode/Streetcode.Auth.BLL/MediatR/LoginWithGoogle/LoginWithGoogleDTOValidator.cs @@ -0,0 +1,21 @@ +using FluentValidation; +using Streetcode.Auth.BLL.DTO.Auth; + +namespace Streetcode.Auth.BLL.MediatR.LoginWithGoogle +{ + public class LoginWithGoogleDTOValidator : AbstractValidator + { + public LoginWithGoogleDTOValidator() + { + RuleFor(x => x.Email) + .NotEmpty().WithMessage("Email is required.") + .EmailAddress().WithMessage("Invalid email format."); + + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Name is required."); + + RuleFor(x => x.Surname) + .NotEmpty().WithMessage("Surname is required."); + } + } +} diff --git a/Streetcode/Streetcode.Auth.BLL/MediatR/LoginWithGoogle/LoginWithGoogleHandler.cs b/Streetcode/Streetcode.Auth.BLL/MediatR/LoginWithGoogle/LoginWithGoogleHandler.cs new file mode 100644 index 0000000..c23b1aa --- /dev/null +++ b/Streetcode/Streetcode.Auth.BLL/MediatR/LoginWithGoogle/LoginWithGoogleHandler.cs @@ -0,0 +1,78 @@ +using AutoMapper; +using FluentResults; +using MassTransit; +using MediatR; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Streetcode.Auth.BLL.DTO.Auth; +using Streetcode.Auth.BLL.DTO.Users; +using Streetcode.Auth.BLL.Interfaces; +using Streetcode.Auth.BLL.MediatR.Login; +using Streetcode.Auth.DAL.Entities; +using Streetcode.Shared.DTO.Events; +using Streetcode.Shared.Enums; + +namespace Streetcode.Auth.BLL.MediatR.LoginWithGoogle +{ + public class LoginWithGoogleHandler : IRequestHandler> + { + private readonly ILogger _logger; + private readonly ITokenService _tokenService; + private readonly UserManager _userManager; + private readonly IMapper _mapper; + private readonly IConfiguration _configuration; + private readonly IPublishEndpoint _publishEndpoint; + + public LoginWithGoogleHandler(ILogger logger, ITokenService tokenService, UserManager userManager, IMapper mapper, IConfiguration configuration, IPublishEndpoint publishEndpoint) + { + _logger = logger; + _tokenService = tokenService; + _userManager = userManager; + _mapper = mapper; + _configuration = configuration; + _publishEndpoint = publishEndpoint; + } + + public async Task> Handle(LoginWithGoogleCommand request, CancellationToken cancellationToken) + { + var user = await _userManager.FindByEmailAsync(request.LoginGoogle.Email); + + if (user == null) + { + user = _mapper.Map(request.LoginGoogle); + + var createResult = await _userManager.CreateAsync(user); + + if (!createResult.Succeeded) + { + return Result.Fail(createResult.Errors.Select(e => e.Description)); + } + + await _userManager.AddToRoleAsync(user, nameof(UserRole.User)); + + await _publishEndpoint.Publish( + new UserRegisteredEvent + { + UserId = user.Id, + Email = user.Email, + Name = user.Name, + Surname = user.Surname, + Role = UserRole.User + }, + cancellationToken); + } + + var (accessToken, refreshToken) = await _tokenService.GenerateTokensAsync(user); + + var responseDto = new TokenResponseDTO + { + AccessToken = accessToken, + AccessTokenExpiresAt = DateTime.UtcNow.AddMinutes(double.Parse(_configuration["Jwt:ExpireMinutes"] !)), + User = _mapper.Map(user) + }; + + return Result.Ok((responseDto, refreshToken.Token)); + } + } +} \ No newline at end of file diff --git a/Streetcode/Streetcode.Auth.WebApi/Controllers/AuthController.cs b/Streetcode/Streetcode.Auth.WebApi/Controllers/AuthController.cs index 5798ccd..c87c36d 100644 --- a/Streetcode/Streetcode.Auth.WebApi/Controllers/AuthController.cs +++ b/Streetcode/Streetcode.Auth.WebApi/Controllers/AuthController.cs @@ -1,7 +1,12 @@ -using MediatR; +using System.Security.Claims; +using MediatR; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.Google; using Microsoft.AspNetCore.Mvc; using Streetcode.Auth.BLL.DTO.Auth; using Streetcode.Auth.BLL.MediatR.Login; +using Streetcode.Auth.BLL.MediatR.LoginWithGoogle; using Streetcode.Auth.BLL.MediatR.Logout; using Streetcode.Auth.BLL.MediatR.RefreshToken; using Streetcode.Auth.BLL.MediatR.Register; @@ -76,6 +81,50 @@ public async Task RefreshToken() return Ok(responseDto); } + [HttpGet("login-google")] + public IActionResult LoginGoogle() + { + var redirectUrl = Url.Action("GoogleCallback"); + var properties = new AuthenticationProperties { RedirectUri = redirectUrl }; + + return Challenge(properties, GoogleDefaults.AuthenticationScheme); + } + + [HttpGet("google-callback")] + public async Task GoogleCallback() + { + var result = await HttpContext.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme); + + if (!result.Succeeded) + { + return BadRequest(new { error = "Google authentication failed" }); + } + + var email = result.Principal.FindFirstValue(ClaimTypes.Email); + var name = result.Principal.FindFirstValue(ClaimTypes.Name); + var surname = result.Principal.FindFirstValue(ClaimTypes.Surname); + + var command = new LoginWithGoogleCommand(new LoginWithGoogleDTO() + { + Email = email, + Name = name, + Surname = surname + }); + + var loginResult = await _mediator.Send(command); + + if (loginResult.IsFailed) + { + return Unauthorized(loginResult.Errors.Select(e => e.Message)); + } + + var (responseDto, refreshToken) = loginResult.Value; + + _cookieService.SetRefreshTokenCookie(Response, refreshToken); + + return Ok(responseDto); + } + [HttpPost("logout")] public async Task Logout() { diff --git a/Streetcode/Streetcode.Auth.WebApi/Extensions/ServiceCollectionExtensions.cs b/Streetcode/Streetcode.Auth.WebApi/Extensions/ServiceCollectionExtensions.cs index 65e4dec..0cd641f 100644 --- a/Streetcode/Streetcode.Auth.WebApi/Extensions/ServiceCollectionExtensions.cs +++ b/Streetcode/Streetcode.Auth.WebApi/Extensions/ServiceCollectionExtensions.cs @@ -1,8 +1,11 @@ using System.Reflection; using System.Text; using FluentValidation; +using Hangfire; +using Hangfire.SqlServer; using MassTransit; using MediatR; +using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; @@ -18,8 +21,6 @@ using Streetcode.Auth.DAL.Repositories.Realizations; using Streetcode.Auth.WebApi.Services.Interfaces; using Streetcode.Auth.WebApi.Services.Realizations; -using Hangfire; -using Hangfire.SqlServer; namespace Streetcode.Auth.WebApi.Extensions; @@ -66,6 +67,7 @@ public static void AddIdentityServices(this IServiceCollection services, IConfig options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; }) + .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.TokenValidationParameters = new TokenValidationParameters @@ -78,6 +80,13 @@ public static void AddIdentityServices(this IServiceCollection services, IConfig ValidAudience = jwtOptions.Audience, IssuerSigningKey = new SymmetricSecurityKey(key) }; + }) + .AddGoogle(options => + { + options.ClientId = configuration["Authentication:Google:ClientId"]; + options.ClientSecret = configuration["Authentication:Google:ClientSecret"]; + + options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme; }); } @@ -181,7 +190,10 @@ public static void AddHangfireServices(this IServiceCollection services, IConfig services.AddHangfire(config => { - config.UseSqlServerStorage(connectionString); + config.UseSqlServerStorage(connectionString, new SqlServerStorageOptions + { + PrepareSchemaIfNecessary = true + }); }); services.AddHangfireServer(); diff --git a/Streetcode/Streetcode.Auth.WebApi/Streetcode.Auth.WebApi.csproj b/Streetcode/Streetcode.Auth.WebApi/Streetcode.Auth.WebApi.csproj index abf3452..dc40224 100644 --- a/Streetcode/Streetcode.Auth.WebApi/Streetcode.Auth.WebApi.csproj +++ b/Streetcode/Streetcode.Auth.WebApi/Streetcode.Auth.WebApi.csproj @@ -11,6 +11,8 @@ + + diff --git a/Streetcode/Streetcode.Auth.WebApi/appsettings.json b/Streetcode/Streetcode.Auth.WebApi/appsettings.json index 1ad7e6d..aed5298 100644 --- a/Streetcode/Streetcode.Auth.WebApi/appsettings.json +++ b/Streetcode/Streetcode.Auth.WebApi/appsettings.json @@ -29,5 +29,11 @@ "Admin": { "Email": "admin@streetcode.com", "Password": "SomePassword" + }, + "Authentication": { + "Google": { + "ClientId": "clientID", + "ClientSecret": "clientSecret" + } } -} \ No newline at end of file + } \ No newline at end of file diff --git a/Streetcode/Streetcode.Auth.XUnitTest/LoginWithGoogle/LoginWithGoogleDTOValidatorTests.cs b/Streetcode/Streetcode.Auth.XUnitTest/LoginWithGoogle/LoginWithGoogleDTOValidatorTests.cs new file mode 100644 index 0000000..b304461 --- /dev/null +++ b/Streetcode/Streetcode.Auth.XUnitTest/LoginWithGoogle/LoginWithGoogleDTOValidatorTests.cs @@ -0,0 +1,94 @@ +namespace Streetcode.Auth.XUnitTest.LoginWithGoogle +{ + using FluentValidation.TestHelper; + using Streetcode.Auth.BLL.DTO.Auth; + using Streetcode.Auth.BLL.MediatR.LoginWithGoogle; + using Xunit; + + public class LoginWithGoogleDTOValidatorTests + { + private readonly LoginWithGoogleDTOValidator validator; + + public LoginWithGoogleDTOValidatorTests() + { + this.validator = new LoginWithGoogleDTOValidator(); + } + + [Theory] + [InlineData("")] + [InlineData(null)] + public void ShouldHaveError_WhenEmailIsEmpty(string email) + { + var model = new LoginWithGoogleDTO { Email = email }; + var result = this.validator.TestValidate(model); + + result.ShouldHaveValidationErrorFor(x => x.Email) + .WithErrorMessage("Email is required."); + } + + [Theory] + [InlineData("plainaddress")] + [InlineData("#@%^%#$@#$@#.com")] + [InlineData("@example.com")] + [InlineData("email.example.com")] + public void ShouldHaveError_WhenEmailIsInvalidFormat(string email) + { + var model = new LoginWithGoogleDTO { Email = email }; + var result = this.validator.TestValidate(model); + + result.ShouldHaveValidationErrorFor(x => x.Email) + .WithErrorMessage("Invalid email format."); + } + + [Theory] + [InlineData("")] + [InlineData(null)] + public void ShouldHaveError_WhenNameIsEmpty(string name) + { + var model = new LoginWithGoogleDTO { Name = name, Email = "test@gmail.com", Surname = "Test" }; + var result = this.validator.TestValidate(model); + + result.ShouldHaveValidationErrorFor(x => x.Name) + .WithErrorMessage("Name is required."); + } + + [Theory] + [InlineData("")] + [InlineData(null)] + public void ShouldHaveError_WhenSurnameIsEmpty(string surname) + { + // Arrange + var model = new LoginWithGoogleDTO + { + Surname = surname, + Email = "test@gmail.com", + Name = "John" + }; + + // Act + var result = this.validator.TestValidate(model); + + // Assert + result.ShouldHaveValidationErrorFor(x => x.Surname) + .WithErrorMessage("Surname is required."); + } + + [Fact] + public void ShouldNotHaveError_WhenDTOIsValid() + { + // Arrange + var model = new LoginWithGoogleDTO + { + Email = "google.user@gmail.com", + Name = "John", + Surname = "Smith" + }; + + // Act + var result = this.validator.TestValidate(model); + + // Assert + result.ShouldNotHaveAnyValidationErrors(); + } + } +} \ No newline at end of file diff --git a/Streetcode/Streetcode.Auth.XUnitTest/LoginWithGoogle/LoginWithGoogleHandlerTests.cs b/Streetcode/Streetcode.Auth.XUnitTest/LoginWithGoogle/LoginWithGoogleHandlerTests.cs new file mode 100644 index 0000000..6975902 --- /dev/null +++ b/Streetcode/Streetcode.Auth.XUnitTest/LoginWithGoogle/LoginWithGoogleHandlerTests.cs @@ -0,0 +1,152 @@ +namespace Streetcode.Auth.XUnitTest.LoginWithGoogle +{ + using AutoMapper; + using FluentAssertions; + using MassTransit; + using Microsoft.AspNetCore.Identity; + using Microsoft.Extensions.Configuration; + using Microsoft.Extensions.Logging; + using Moq; + using Streetcode.Auth.BLL.DTO.Auth; + using Streetcode.Auth.BLL.DTO.Users; + using Streetcode.Auth.BLL.Interfaces; + using Streetcode.Auth.BLL.Mapping; + using Streetcode.Auth.BLL.MediatR.LoginWithGoogle; + using Streetcode.Auth.DAL.Entities; + using Streetcode.Shared.DTO.Events; + using Xunit; + + public class LoginWithGoogleHandlerTests + { + private const string Email = "google-user@gmail.com"; + private const string Name = "John"; + private const string Surname = "Smith"; + private const string AccessToken = "google_access_token"; + private const string RefreshTokenStr = "google_refresh_token"; + + private readonly Mock> userManagerMock; + private readonly Mock tokenServiceMock; + private readonly Mock configMock; + private readonly Mock> loggerMock; + private readonly Mock publishEndpointMock; + private readonly IMapper mapper; + private readonly LoginWithGoogleHandler handler; + + public LoginWithGoogleHandlerTests() + { + this.userManagerMock = this.CreateUserManagerMock(); + this.tokenServiceMock = new Mock(); + this.configMock = new Mock(); + this.loggerMock = new Mock>(); + this.publishEndpointMock = new Mock(); + + var configuration = new MapperConfiguration(cfg => + { + cfg.AddProfile(new AuthProfile()); + }); + this.mapper = configuration.CreateMapper(); + + this.handler = new LoginWithGoogleHandler( + this.loggerMock.Object, + this.tokenServiceMock.Object, + this.userManagerMock.Object, + this.mapper, + this.configMock.Object, + this.publishEndpointMock.Object); + } + + [Fact] + public async Task Handle_ShouldReturnSuccess_WhenUserExists() + { + // Arrange + var user = new ApplicationUser + { + Id = "google-id-123", + Email = Email, + UserName = Email, + Name = Name, + Surname = Surname + }; + var refreshTokenEntity = new RefreshToken { Token = RefreshTokenStr, UserId = user.Id }; + + this.userManagerMock + .Setup(m => m.FindByEmailAsync(Email)) + .ReturnsAsync(user); + + this.tokenServiceMock + .Setup(s => s.GenerateTokensAsync(user)) + .ReturnsAsync((AccessToken, refreshTokenEntity)); + + this.configMock.Setup(c => c["Jwt:ExpireMinutes"]).Returns("60"); + + var command = new LoginWithGoogleCommand(new LoginWithGoogleDTO + { + Email = Email, + Name = Name, + Surname = Surname + }); + + // Act + var result = await handler.Handle(command, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + var (responseDto, refreshToken) = result.Value; + + refreshToken.Should().Be(RefreshTokenStr); + responseDto.AccessToken.Should().Be(AccessToken); + responseDto.User.Email.Should().Be(Email); + responseDto.User.Surname.Should().Be(Surname); + + this.tokenServiceMock.Verify(s => s.GenerateTokensAsync(user), Times.Once); + } + + [Fact] + public async Task Handle_ShouldCreateUserAndReturnSuccess_WhenUserDoesNotExist() + { + // Arrange + this.userManagerMock + .Setup(m => m.FindByEmailAsync(Email)) + .ReturnsAsync((ApplicationUser?)null); + + this.userManagerMock + .Setup(m => m.CreateAsync(It.IsAny())) + .ReturnsAsync(IdentityResult.Success); + + this.userManagerMock + .Setup(m => m.AddToRoleAsync(It.IsAny(), "User")) + .ReturnsAsync(IdentityResult.Success); + + var user = new ApplicationUser { Id = "new-id", Email = Email }; + var refreshTokenEntity = new RefreshToken { Token = RefreshTokenStr, UserId = user.Id }; + + this.tokenServiceMock + .Setup(s => s.GenerateTokensAsync(It.IsAny())) + .ReturnsAsync((AccessToken, refreshTokenEntity)); + + this.configMock.Setup(c => c["Jwt:ExpireMinutes"]).Returns("60"); + + var command = new LoginWithGoogleCommand(new LoginWithGoogleDTO + { + Email = Email, + Name = "New", + Surname = "User" + }); + + // Act + var result = await handler.Handle(command, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + this.userManagerMock.Verify(m => m.CreateAsync(It.IsAny()), Times.Once); + this.publishEndpointMock.Verify(p => p.Publish(It.IsAny(), It.IsAny()), Times.Once); + } + + private Mock> CreateUserManagerMock() + { + var store = new Mock>(); + return new Mock>( + store.Object, null, null, null, null, null, null, null, null); + } + } +} \ No newline at end of file diff --git a/Streetcode/Streetcode.BLL/DTO/Email/EmailDTO.cs b/Streetcode/Streetcode.BLL/DTO/Email/EmailDTO.cs index 8635ee1..137074b 100644 --- a/Streetcode/Streetcode.BLL/DTO/Email/EmailDTO.cs +++ b/Streetcode/Streetcode.BLL/DTO/Email/EmailDTO.cs @@ -1,9 +1,13 @@ -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; namespace Streetcode.BLL.DTO.Email { public class EmailDTO { + public EmailDTO() + { + } + [MaxLength(80)] public string From { get; set; } @@ -11,4 +15,4 @@ public class EmailDTO [StringLength(500, MinimumLength = 1)] public string Content { get; set; } } -} +} \ No newline at end of file diff --git a/Streetcode/Streetcode.BLL/Interfaces/Email/IEmailService.cs b/Streetcode/Streetcode.BLL/Interfaces/Email/IEmailService.cs deleted file mode 100644 index 7817456..0000000 --- a/Streetcode/Streetcode.BLL/Interfaces/Email/IEmailService.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Streetcode.DAL.Entities.AdditionalContent.Email; - -namespace Streetcode.BLL.Interfaces.Email -{ - public interface IEmailService - { - Task SendEmailAsync(Message message); - } -} diff --git a/Streetcode/Streetcode.BLL/MediatR/Email/SendEmailCommand.cs b/Streetcode/Streetcode.BLL/MediatR/Email/SendEmailCommand.cs deleted file mode 100644 index 7a7d1da..0000000 --- a/Streetcode/Streetcode.BLL/MediatR/Email/SendEmailCommand.cs +++ /dev/null @@ -1,6 +0,0 @@ -using FluentResults; -using MediatR; -using Streetcode.BLL.DTO.Email; - -namespace Streetcode.BLL.MediatR.Email; -public record SendEmailCommand(EmailDTO Email) : IRequest>; diff --git a/Streetcode/Streetcode.BLL/MediatR/Email/SendEmailHandler.cs b/Streetcode/Streetcode.BLL/MediatR/Email/SendEmailHandler.cs deleted file mode 100644 index c16e438..0000000 --- a/Streetcode/Streetcode.BLL/MediatR/Email/SendEmailHandler.cs +++ /dev/null @@ -1,42 +0,0 @@ -using FluentResults; -using MediatR; -using Streetcode.BLL.Interfaces.Email; -using Streetcode.BLL.Interfaces.Logging; -using Streetcode.DAL.Entities.AdditionalContent.Email; -using Streetcode.Resources; -using Streetcode.Shared; - -namespace Streetcode.BLL.MediatR.Email -{ - public class SendEmailHandler : IRequestHandler> - { - private readonly IEmailService _emailService; - private readonly ILoggerService _logger; - - public SendEmailHandler(IEmailService emailService, ILoggerService logger) - { - _emailService = emailService; - _logger = logger; - } - - public async Task> Handle(SendEmailCommand request, CancellationToken cancellationToken) - { - var message = new Message( - [Constants.StreetcodeContacts.Email], - request.Email.From, - "FeedBack", - request.Email.Content); - - var isResultSuccess = await _emailService.SendEmailAsync(message); - - if (isResultSuccess) - { - return Result.Ok(Unit.Value); - } - - var errorMsg = Messages.Error_FailedToSendEmail; - _logger.LogError(request, errorMsg); - return Result.Fail(new Error(errorMsg)); - } - } -} diff --git a/Streetcode/Streetcode.BLL/Services/Email/EmailService.cs b/Streetcode/Streetcode.BLL/Services/Email/EmailService.cs deleted file mode 100644 index 0c3d093..0000000 --- a/Streetcode/Streetcode.BLL/Services/Email/EmailService.cs +++ /dev/null @@ -1,70 +0,0 @@ -using MailKit.Net.Smtp; -using MimeKit; -using Streetcode.BLL.Interfaces.Email; -using Streetcode.DAL.Entities.AdditionalContent.Email; - -namespace Streetcode.BLL.Services.Email -{ - public class EmailService : IEmailService - { - private readonly EmailConfiguration _emailConfig; - - public EmailService(EmailConfiguration emailConfig) - { - _emailConfig = emailConfig; - } - - public async Task SendEmailAsync(Message message) - { - var mailMessage = CreateEmailMessage(message); - - return await SendAsync(mailMessage); - } - - private MimeMessage CreateEmailMessage(Message message) - { - var emailMessage = new MimeMessage(); - emailMessage.From.Add(new MailboxAddress("", _emailConfig.From)); - emailMessage.To.AddRange(message.To); - emailMessage.Subject = message.Subject; - - var bodyBuilder = new BodyBuilder - { - HtmlBody = - "

" + - $"Від: {message.From}
" + - $"Текст: {message.Content}" + - "

" - }; - - emailMessage.Body = bodyBuilder.ToMessageBody(); - return emailMessage; - } - - private async Task SendAsync(MimeMessage mailMessage) - { - using (var client = new SmtpClient()) - { - try - { - await client.ConnectAsync(_emailConfig.SmtpServer, _emailConfig.Port, true); - client.AuthenticationMechanisms.Remove("XOAUTH2"); - await client.AuthenticateAsync(_emailConfig.UserName, _emailConfig.Password); - - await client.SendAsync(mailMessage); - return true; - } - catch - { - // Logger - return false; - } - finally - { - await client.DisconnectAsync(true); - client.Dispose(); - } - } - } - } -} diff --git a/Streetcode/Streetcode.Email.BLL/Configs/EmailConfiguration.cs b/Streetcode/Streetcode.Email.BLL/Configs/EmailConfiguration.cs new file mode 100644 index 0000000..de289d4 --- /dev/null +++ b/Streetcode/Streetcode.Email.BLL/Configs/EmailConfiguration.cs @@ -0,0 +1,14 @@ +namespace Streetcode.Email.BLL.Configs +{ + public class EmailConfiguration + { + public const string SectionName = "EmailConfiguration"; + + public string FromAddress { get; set; } + public string AdminAddress { get; set; } + public string SmtpServer { get; set; } + public int Port { get; set; } + public string SmtpUser { get; set; } + public string SmtpPassword { get; set; } + } +} diff --git a/Streetcode/Streetcode.Email.BLL/DTO/EmailDTO.cs b/Streetcode/Streetcode.Email.BLL/DTO/EmailDTO.cs new file mode 100644 index 0000000..dfd14e3 --- /dev/null +++ b/Streetcode/Streetcode.Email.BLL/DTO/EmailDTO.cs @@ -0,0 +1,11 @@ +namespace Streetcode.Email.BLL.DTO +{ + public class EmailDTO + { + public EmailDTO() + { + } + public string From { get; set; } + public string Content { get; set; } + } +} diff --git a/Streetcode/Streetcode.Email.BLL/Exceptions/ValidationException.cs b/Streetcode/Streetcode.Email.BLL/Exceptions/ValidationException.cs new file mode 100644 index 0000000..d2c6dd1 --- /dev/null +++ b/Streetcode/Streetcode.Email.BLL/Exceptions/ValidationException.cs @@ -0,0 +1,29 @@ +using FluentValidation.Results; + +namespace Streetcode.Email.BLL.Exceptions +{ + public class ValidationException : Exception + { + public ValidationException() : base("One or more validation failures have occurred.") + { + Errors = new Dictionary(); + } + + public ValidationException(IEnumerable failures) : this() + { + Errors = failures + .GroupBy(e => e.PropertyName, e => e.ErrorMessage) + .ToDictionary(failureGroup => failureGroup.Key, failureGroup => failureGroup.ToArray()); + } + + public ValidationException(string propertyName, string errorMessage) : this() + { + Errors = new Dictionary + { + { propertyName, new[] { errorMessage } } + }; + } + + public IDictionary Errors { get; } + } +} \ No newline at end of file diff --git a/Streetcode/Streetcode.Email.BLL/Interfaces/IEmailService.cs b/Streetcode/Streetcode.Email.BLL/Interfaces/IEmailService.cs new file mode 100644 index 0000000..9582664 --- /dev/null +++ b/Streetcode/Streetcode.Email.BLL/Interfaces/IEmailService.cs @@ -0,0 +1,9 @@ +using Streetcode.Email.BLL.DTO; + +namespace Streetcode.Email.BLL.Interfaces +{ + public interface IEmailService + { + Task SendEmailAsync(EmailDTO email); + } +} \ No newline at end of file diff --git a/Streetcode/Streetcode.Email.BLL/Mapping/EmailProfile.cs b/Streetcode/Streetcode.Email.BLL/Mapping/EmailProfile.cs new file mode 100644 index 0000000..7bc2a7f --- /dev/null +++ b/Streetcode/Streetcode.Email.BLL/Mapping/EmailProfile.cs @@ -0,0 +1,14 @@ +using AutoMapper; +using Streetcode.Email.BLL.DTO; +using EmailEntity = Streetcode.Email.DAL.Entities.Email; + +namespace Streetcode.Email.BLL.Mapping +{ + public class EmailProfile : Profile + { + public EmailProfile() + { + CreateMap().ReverseMap(); + } + } +} diff --git a/Streetcode/Streetcode.Email.BLL/MediatR/Behavior/ValidatorBehavior.cs b/Streetcode/Streetcode.Email.BLL/MediatR/Behavior/ValidatorBehavior.cs new file mode 100644 index 0000000..36213eb --- /dev/null +++ b/Streetcode/Streetcode.Email.BLL/MediatR/Behavior/ValidatorBehavior.cs @@ -0,0 +1,33 @@ +using FluentValidation; +using MediatR; + +namespace Streetcode.Email.BLL.MediatR.Behavior +{ + public class ValidatorBehavior : IPipelineBehavior + where TRequest : IRequest + { + private readonly IEnumerable> _validators; + + public ValidatorBehavior(IEnumerable> validators) + { + _validators = validators; + } + + public Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + var context = new ValidationContext(request); + var failures = _validators + .Select(v => v.Validate(context)) + .SelectMany(result => result.Errors) + .Where(f => f != null) + .ToList(); + + if (failures.Count != 0) + { + throw new Exceptions.ValidationException(failures); + } + + return next(); + } + } +} diff --git a/Streetcode/Streetcode.Email.BLL/MediatR/Email/EmailDTOValidator.cs b/Streetcode/Streetcode.Email.BLL/MediatR/Email/EmailDTOValidator.cs new file mode 100644 index 0000000..5ccf163 --- /dev/null +++ b/Streetcode/Streetcode.Email.BLL/MediatR/Email/EmailDTOValidator.cs @@ -0,0 +1,20 @@ +using FluentValidation; +using Streetcode.Email.BLL.DTO; + +namespace Streetcode.Email.BLL.MediatR.Email +{ + public class EmailDTOValidator : AbstractValidator + { + public EmailDTOValidator() + { + RuleFor(x => x.From) + .NotEmpty() + .EmailAddress(); + + RuleFor(x => x.Content) + .NotEmpty() + .MinimumLength(5) + .MaximumLength(1000); + } + } +} diff --git a/Streetcode/Streetcode.Email.BLL/MediatR/Email/SendEmailCommand.cs b/Streetcode/Streetcode.Email.BLL/MediatR/Email/SendEmailCommand.cs new file mode 100644 index 0000000..98a028a --- /dev/null +++ b/Streetcode/Streetcode.Email.BLL/MediatR/Email/SendEmailCommand.cs @@ -0,0 +1,8 @@ +using FluentResults; +using MediatR; +using Streetcode.Email.BLL.DTO; + +namespace Streetcode.Email.BLL.MediatR.Email +{ + public record SendEmailCommand(EmailDTO email) : IRequest>; +} diff --git a/Streetcode/Streetcode.Email.BLL/MediatR/Email/SendEmailCommandValidator.cs b/Streetcode/Streetcode.Email.BLL/MediatR/Email/SendEmailCommandValidator.cs new file mode 100644 index 0000000..596df14 --- /dev/null +++ b/Streetcode/Streetcode.Email.BLL/MediatR/Email/SendEmailCommandValidator.cs @@ -0,0 +1,16 @@ +using FluentValidation; +using Streetcode.Resources; + +namespace Streetcode.Email.BLL.MediatR.Email +{ + public class SendEmailCommandValidator : AbstractValidator + { + public SendEmailCommandValidator() + { + RuleFor(x => x.email) + .NotNull() + .WithMessage(Messages.Error_CommandDataRequired) + .SetValidator(new EmailDTOValidator()); + } + } +} diff --git a/Streetcode/Streetcode.Email.BLL/MediatR/Email/SendEmailHandler.cs b/Streetcode/Streetcode.Email.BLL/MediatR/Email/SendEmailHandler.cs new file mode 100644 index 0000000..76ae843 --- /dev/null +++ b/Streetcode/Streetcode.Email.BLL/MediatR/Email/SendEmailHandler.cs @@ -0,0 +1,52 @@ +using AutoMapper; +using FluentResults; +using Hangfire; +using MediatR; +using Microsoft.Extensions.Logging; +using Streetcode.Email.BLL.DTO; +using Streetcode.Email.BLL.Interfaces; +using Streetcode.Email.DAL.Persistence; +using Streetcode.Resources; +using EmailEntity = Streetcode.Email.DAL.Entities.Email; + +namespace Streetcode.Email.BLL.MediatR.Email +{ + public class SendEmailHandler : IRequestHandler> + { + private readonly EmailDbContext _context; + private readonly IMapper _mapper; + private readonly ILogger _logger; + private readonly IBackgroundJobClient _backgroundJob; + + public SendEmailHandler(EmailDbContext context, IMapper mapper, ILogger logger, IBackgroundJobClient backgroundJob) + { + _context = context; + _mapper = mapper; + _logger = logger; + _backgroundJob = backgroundJob; + } + + public async Task> Handle(SendEmailCommand request, CancellationToken cancellationToken) + { + var EmailEntity = _mapper.Map(request.email); + + _context.Emails.Add(EmailEntity); + + var rowsAffected = await _context.SaveChangesAsync(cancellationToken); + + if (rowsAffected <= 0) + { + var errorMsg = Messages.Error_FailedToCreateEntity; + _logger.LogError(errorMsg); + return Result.Fail(errorMsg); + } + + _backgroundJob.Enqueue(emailService => + emailService.SendEmailAsync(request.email)); + + _logger.LogInformation("Email saved to DB and email task enqueued for {Email}", request.email.From); + + return Result.Ok(Unit.Value); + } + } +} diff --git a/Streetcode/Streetcode.Email.BLL/Services/EmailConsumer.cs b/Streetcode/Streetcode.Email.BLL/Services/EmailConsumer.cs new file mode 100644 index 0000000..7fe53db --- /dev/null +++ b/Streetcode/Streetcode.Email.BLL/Services/EmailConsumer.cs @@ -0,0 +1,33 @@ +using MassTransit; +using MediatR; +using Streetcode.Email.BLL.DTO; +using Streetcode.Email.BLL.MediatR.Email; +using Streetcode.Shared.Contracts; + +namespace Streetcode.Email.BLL.Services +{ + public class EmailConsumer : IConsumer + { + private readonly IMediator _mediator; + public EmailConsumer(IMediator mediator) + { + _mediator = mediator; + } + + public async Task Consume(ConsumeContext context) + { + var message = context.Message; + + var email = new EmailDTO + { + From = message.From, + Content = message.Content + }; + + var command = new SendEmailCommand(email); + + await _mediator.Send(command); + } + } +} + diff --git a/Streetcode/Streetcode.Email.BLL/Services/EmailService.cs b/Streetcode/Streetcode.Email.BLL/Services/EmailService.cs new file mode 100644 index 0000000..92ed4d1 --- /dev/null +++ b/Streetcode/Streetcode.Email.BLL/Services/EmailService.cs @@ -0,0 +1,55 @@ +using MailKit.Net.Smtp; +using MailKit.Security; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using MimeKit; +using Streetcode.Email.BLL.Configs; +using Streetcode.Email.BLL.DTO; +using Streetcode.Email.BLL.Interfaces; +using System.Data; + +namespace Streetcode.Email.BLL.Services +{ + public class EmailService : IEmailService + { + private readonly EmailConfiguration _emailConfig; + private readonly ILogger _logger; + + public EmailService(IOptions options, ILogger logger) + { + _emailConfig = options.Value; + _logger = logger; + } + + public async Task SendEmailAsync(EmailDTO email) + { + try + { + var message = new MimeMessage(); + message.From.Add(new MailboxAddress("Streetcode", _emailConfig.FromAddress)); + message.To.Add(new MailboxAddress("Streetcode Admin", _emailConfig.AdminAddress)); + message.Subject = $"New feedback from Streetcode User"; + message.Body = new TextPart("plain") + { + Text = $"Користувач {email.From} залишив повідомлення:\n\n{email.Content}" + }; + + using var client = new SmtpClient(); + + await client.ConnectAsync(_emailConfig.SmtpServer, _emailConfig.Port, SecureSocketOptions.Auto); + await client.AuthenticateAsync(_emailConfig.SmtpUser, _emailConfig.SmtpPassword); + + await client.SendAsync(message); + await client.DisconnectAsync(true); + + _logger.LogInformation("Email sent successfully to {Recipient} from {Sender}.", _emailConfig.AdminAddress, email.From); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to send email to {Recipient} due to an error.", _emailConfig.AdminAddress); + + throw; + } + } + } +} diff --git a/Streetcode/Streetcode.Email.BLL/Streetcode.Email.BLL.csproj b/Streetcode/Streetcode.Email.BLL/Streetcode.Email.BLL.csproj new file mode 100644 index 0000000..10da8bc --- /dev/null +++ b/Streetcode/Streetcode.Email.BLL/Streetcode.Email.BLL.csproj @@ -0,0 +1,28 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + + + + + + + + + diff --git a/Streetcode/Streetcode.Email.DAL/Entities/Email.cs b/Streetcode/Streetcode.Email.DAL/Entities/Email.cs new file mode 100644 index 0000000..3f6920f --- /dev/null +++ b/Streetcode/Streetcode.Email.DAL/Entities/Email.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Streetcode.Email.DAL.Entities; + +[Table("emails", Schema = "email")] +public class Email + { + [Key] + public int Id { get; set; } + [Required] + [EmailAddress] + public string? From { get; set; } + [Required] + [MinLength(5)] + [MaxLength(1000)] + public string? Content { get; set; } + } + diff --git a/Streetcode/Streetcode.Email.DAL/Migrations/20260227122516_Name.Designer.cs b/Streetcode/Streetcode.Email.DAL/Migrations/20260227122516_Name.Designer.cs new file mode 100644 index 0000000..ede2f6a --- /dev/null +++ b/Streetcode/Streetcode.Email.DAL/Migrations/20260227122516_Name.Designer.cs @@ -0,0 +1,52 @@ +// +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Streetcode.Email.DAL.Persistence; + +#nullable disable + +namespace Streetcode.Email.DAL.Migrations +{ + [DbContext(typeof(EmailDbContext))] + [Migration("20260227122516_Name")] + partial class Name + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.23") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Streetcode.Email.DAL.Entities.Feedback", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.ToTable("Feedbacks", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Streetcode/Streetcode.Email.DAL/Migrations/20260227122516_Name.cs b/Streetcode/Streetcode.Email.DAL/Migrations/20260227122516_Name.cs new file mode 100644 index 0000000..2ec9e22 --- /dev/null +++ b/Streetcode/Streetcode.Email.DAL/Migrations/20260227122516_Name.cs @@ -0,0 +1,35 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Streetcode.Email.DAL.Migrations +{ + /// + public partial class Name : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Feedbacks", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Email = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: false), + Message = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Feedbacks", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Feedbacks"); + } + } +} diff --git a/Streetcode/Streetcode.Email.DAL/Migrations/20260302112047_RenameFeedbackIntoEmail.Designer.cs b/Streetcode/Streetcode.Email.DAL/Migrations/20260302112047_RenameFeedbackIntoEmail.Designer.cs new file mode 100644 index 0000000..9d2b1b6 --- /dev/null +++ b/Streetcode/Streetcode.Email.DAL/Migrations/20260302112047_RenameFeedbackIntoEmail.Designer.cs @@ -0,0 +1,52 @@ +// +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Streetcode.Email.DAL.Persistence; + +#nullable disable + +namespace Streetcode.Email.DAL.Migrations +{ + [DbContext(typeof(EmailDbContext))] + [Migration("20260302112047_RenameFeedbackIntoEmail")] + partial class RenameFeedbackIntoEmail + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.23") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Streetcode.Email.DAL.Entities.Email", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Content") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("From") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.ToTable("Emails", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Streetcode/Streetcode.Email.DAL/Migrations/20260302112047_RenameFeedbackIntoEmail.cs b/Streetcode/Streetcode.Email.DAL/Migrations/20260302112047_RenameFeedbackIntoEmail.cs new file mode 100644 index 0000000..331f7ce --- /dev/null +++ b/Streetcode/Streetcode.Email.DAL/Migrations/20260302112047_RenameFeedbackIntoEmail.cs @@ -0,0 +1,66 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Streetcode.Email.DAL.Migrations +{ + /// + public partial class RenameFeedbackIntoEmail : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameTable( + name: "Feedbacks", + newName: "Emails"); + + migrationBuilder.RenameColumn( + name: "Email", + table: "Emails", + newName: "From"); + + migrationBuilder.RenameColumn( + name: "Message", + table: "Emails", + newName: "Content"); + + migrationBuilder.AlterColumn( + name: "Content", + table: "Emails", + type: "nvarchar(1000)", + maxLength: 1000, + nullable: false, + oldClrType: typeof(string), + oldType: "nvarchar(100)", + oldMaxLength: 100); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "Content", + table: "Emails", + type: "nvarchar(100)", + maxLength: 100, + nullable: false, + oldClrType: typeof(string), + oldType: "nvarchar(1000)", + oldMaxLength: 1000); + + migrationBuilder.RenameTable( + name: "Emails", + newName: "Feedbacks"); + + migrationBuilder.RenameColumn( + name: "From", + table: "Feedbacks", + newName: "Email"); + + migrationBuilder.RenameColumn( + name: "Content", + table: "Feedbacks", + newName: "Message"); + } + } +} diff --git a/Streetcode/Streetcode.Email.DAL/Migrations/EmailDbContextModelSnapshot.cs b/Streetcode/Streetcode.Email.DAL/Migrations/EmailDbContextModelSnapshot.cs new file mode 100644 index 0000000..c8de553 --- /dev/null +++ b/Streetcode/Streetcode.Email.DAL/Migrations/EmailDbContextModelSnapshot.cs @@ -0,0 +1,49 @@ +// +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Streetcode.Email.DAL.Persistence; + +#nullable disable + +namespace Streetcode.Email.DAL.Migrations +{ + [DbContext(typeof(EmailDbContext))] + partial class EmailDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.23") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Streetcode.Email.DAL.Entities.Email", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Content") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("From") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.ToTable("Emails", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Streetcode/Streetcode.Email.DAL/Persistence/EmailDbContext.cs b/Streetcode/Streetcode.Email.DAL/Persistence/EmailDbContext.cs new file mode 100644 index 0000000..bbc7001 --- /dev/null +++ b/Streetcode/Streetcode.Email.DAL/Persistence/EmailDbContext.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore; +using EmailEntity = Streetcode.Email.DAL.Entities.Email; + +namespace Streetcode.Email.DAL.Persistence +{ + public class EmailDbContext : DbContext + { + public EmailDbContext(DbContextOptions options) + : base(options) + { + } + + public DbSet Emails { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity(entity => + { + entity.ToTable("Emails"); + entity.HasKey(e => e.Id); + entity.Property(e => e.From).IsRequired().HasMaxLength(256); + entity.Property(e => e.Content).IsRequired(); + }); + } + } +} diff --git a/Streetcode/Streetcode.Email.DAL/Streetcode.Email.DAL.csproj b/Streetcode/Streetcode.Email.DAL/Streetcode.Email.DAL.csproj new file mode 100644 index 0000000..51b57ae --- /dev/null +++ b/Streetcode/Streetcode.Email.DAL/Streetcode.Email.DAL.csproj @@ -0,0 +1,22 @@ + + + + net8.0 + enable + enable + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/Streetcode/Streetcode.Email.WebAPI/Extensions/ServiceCollectionExtensions.cs b/Streetcode/Streetcode.Email.WebAPI/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..356e31b --- /dev/null +++ b/Streetcode/Streetcode.Email.WebAPI/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,91 @@ +using FluentValidation; +using Hangfire; +using Hangfire.SqlServer; +using MassTransit; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Streetcode.Email.BLL.Configs; +using Streetcode.Email.BLL.Interfaces; +using Streetcode.Email.BLL.MediatR.Behavior; +using Streetcode.Email.BLL.Services; +using Streetcode.Email.DAL.Persistence; +using System.Reflection; + +namespace Streetcode.Email.WebAPI.Extensions +{ + public static class ServiceCollectionExtensions + { + public static void AddApplicationServices(this IServiceCollection services, ConfigurationManager configuration) + { + var connectionString = configuration.GetConnectionString("DefaultConnection"); + + services.AddDbContext(options => + { + options.UseSqlServer(connectionString, opt => + { + opt.MigrationsAssembly(typeof(EmailDbContext).Assembly.GetName().Name); + opt.MigrationsHistoryTable("__EFMigrationsHistory", schema: "entity_framework"); + }); + }); + + services.AddHangfire(config => config + .SetDataCompatibilityLevel(CompatibilityLevel.Version_180) + .UseSimpleAssemblyNameTypeSerializer() + .UseRecommendedSerializerSettings() + .UseSqlServerStorage(connectionString, new SqlServerStorageOptions + { + PrepareSchemaIfNecessary = true, + + CommandBatchMaxTimeout = TimeSpan.FromMinutes(5), + SlidingInvisibilityTimeout = TimeSpan.FromMinutes(5), + QueuePollInterval = TimeSpan.Zero, + UseRecommendedIsolationLevel = true, + DisableGlobalLocks = true + })); + + services.AddHangfireServer(); + + services.AddLogging(); + + services.AddControllers(); + } + public static void AddCustomServices(this IServiceCollection services, IConfiguration configuration) + { + var bllAssembly = Assembly.Load("Streetcode.Email.BLL"); + + services.AddAutoMapper(bllAssembly); + services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblies(bllAssembly)); + services.Configure( + configuration.GetSection("EmailConfiguration")); + + services.AddScoped(); + + services.AddValidatorsFromAssembly(bllAssembly); + services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidatorBehavior<,>)); + + services.AddMassTransit(x => + { + x.AddConsumer(); + + x.UsingRabbitMq((context, cfg) => + { + var rabbitSection = configuration.GetSection("RabbitMQ"); + + var host = rabbitSection["Host"] + ?? throw new InvalidOperationException("RabbitMQ Host is missing"); + var username = rabbitSection["Username"] + ?? throw new InvalidOperationException("RabbitMQ Username is missing"); + var password = rabbitSection["Password"] + ?? throw new InvalidOperationException("RabbitMQ Password is missing"); + + cfg.Host(host, "/", h => + { + h.Username(username); + h.Password(password); + }); + cfg.ConfigureEndpoints(context); + }); + }); + } + } +} \ No newline at end of file diff --git a/Streetcode/Streetcode.Email.WebAPI/Extensions/WebApplicationExtensions.cs b/Streetcode/Streetcode.Email.WebAPI/Extensions/WebApplicationExtensions.cs new file mode 100644 index 0000000..c47d63c --- /dev/null +++ b/Streetcode/Streetcode.Email.WebAPI/Extensions/WebApplicationExtensions.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore; +using Streetcode.Email.DAL.Persistence; + +namespace Streetcode.Email.WebAPI.Extensions +{ + public static class WebApplicationExtensions + { + public static async Task ApplyMigrations(this WebApplication app) + { + using var scope = app.Services.CreateScope(); + var services = scope.ServiceProvider; + var logger = services.GetRequiredService>(); + + try + { + var emailContext = services.GetRequiredService(); + await emailContext.Database.MigrateAsync(); + logger.LogInformation("Database migrated successfully."); + } + catch (Exception ex) + { + logger.LogError(ex, "An error occurred during startup migration"); + throw; + } + } + } +} diff --git a/Streetcode/Streetcode.Email.WebAPI/Program.cs b/Streetcode/Streetcode.Email.WebAPI/Program.cs new file mode 100644 index 0000000..9a9684b --- /dev/null +++ b/Streetcode/Streetcode.Email.WebAPI/Program.cs @@ -0,0 +1,27 @@ +using Hangfire; +using Streetcode.Email.BLL.Interfaces; +using Streetcode.Email.WebAPI.Extensions; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddApplicationServices(builder.Configuration); +builder.Services.AddCustomServices(builder.Configuration); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment() || app.Environment.EnvironmentName == "Local") +{ + app.UseHangfireDashboard("/dash"); +} +else +{ + app.UseHsts(); +} + +await app.ApplyMigrations(); + +// app.SeedDataAsync(); // uncomment for seeding data in local + +app.MapControllers(); + +app.Run(); diff --git a/Streetcode/Streetcode.Email.WebAPI/Properties/launchSettings.json b/Streetcode/Streetcode.Email.WebAPI/Properties/launchSettings.json new file mode 100644 index 0000000..d554bda --- /dev/null +++ b/Streetcode/Streetcode.Email.WebAPI/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:48956", + "sslPort": 44318 + } + }, + "profiles": { + "Streetcode_Email_Local": { + "commandName": "Project", + "launchBrowser": false, + "environmentVariables": { + "STREETCODE_ENVIRONMENT": "Local", + "ASPNETCORE_ENVIRONMENT": "Local" + }, + "dotnetRunMessages": true, + "applicationUrl": "https://localhost:7094;http://localhost:5179" + }, + "Streetcode_Email_Dev": { + "commandName": "Project", + "launchBrowser": false, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true, + "applicationUrl": "https://localhost:7094;http://localhost:5179" + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/Streetcode/Streetcode.Email.WebAPI/Streetcode.Email.WebAPI.csproj b/Streetcode/Streetcode.Email.WebAPI/Streetcode.Email.WebAPI.csproj new file mode 100644 index 0000000..a29f5f4 --- /dev/null +++ b/Streetcode/Streetcode.Email.WebAPI/Streetcode.Email.WebAPI.csproj @@ -0,0 +1,32 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + diff --git a/Streetcode/Streetcode.Email.WebAPI/appsettings.json b/Streetcode/Streetcode.Email.WebAPI/appsettings.json new file mode 100644 index 0000000..c451cd8 --- /dev/null +++ b/Streetcode/Streetcode.Email.WebAPI/appsettings.json @@ -0,0 +1,16 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + + "ConnectionStrings": { + "DefaultConnection": "ConnectionString", + "Redis": "Redis" + }, + + +} diff --git a/Streetcode/Streetcode.Email.XUnitTest/Email/EmailCommandValidatorTests.cs b/Streetcode/Streetcode.Email.XUnitTest/Email/EmailCommandValidatorTests.cs new file mode 100644 index 0000000..b8d6c6c --- /dev/null +++ b/Streetcode/Streetcode.Email.XUnitTest/Email/EmailCommandValidatorTests.cs @@ -0,0 +1,70 @@ +namespace Streetcode.Email.XUnitTest.MediatR.Email +{ + using FluentAssertions; + using FluentValidation.TestHelper; + using Streetcode.Email.BLL.DTO; + using Streetcode.Email.BLL.MediatR.Email; + using Streetcode.Resources; + using Xunit; + + public class EmailCommandValidatorTests + { + private readonly SendEmailCommandValidator validator; + + public EmailCommandValidatorTests() + { + this.validator = new SendEmailCommandValidator(); + } + + [Fact] + public void ShouldReturnError_IfEmailIsNull() + { + // Arrange + var command = new SendEmailCommand(null!); + + // Act + var result = this.validator.TestValidate(command); + + // Assert + result.ShouldHaveValidationErrorFor(x => x.email) + .WithErrorMessage(Messages.Error_CommandDataRequired); + } + + [Fact] + public void ShouldHaveError_WhenEmailDTOIsInvalid() + { + // Arrange + var invalidDto = new EmailDTO + { + From = "invalid-email", + Content = "123" + }; + var command = new SendEmailCommand(invalidDto); + + // Act + var result = this.validator.TestValidate(command); + + // Assert + result.ShouldHaveValidationErrorFor(x => x.email.From); + result.ShouldHaveValidationErrorFor(x => x.email.Content); + } + + [Fact] + public void ShouldNotHaveErrors_WhenCommandIsValid() + { + // Arrange + var validDto = new EmailDTO + { + From = "test@gmail.com", + Content = "Valid message content" + }; + var command = new SendEmailCommand(validDto); + + // Act + var result = this.validator.TestValidate(command); + + // Assert + result.ShouldNotHaveAnyValidationErrors(); + } + } +} \ No newline at end of file diff --git a/Streetcode/Streetcode.Email.XUnitTest/Email/EmailDTOValidatorTests.cs b/Streetcode/Streetcode.Email.XUnitTest/Email/EmailDTOValidatorTests.cs new file mode 100644 index 0000000..dbd42f9 --- /dev/null +++ b/Streetcode/Streetcode.Email.XUnitTest/Email/EmailDTOValidatorTests.cs @@ -0,0 +1,95 @@ +namespace Streetcode.Email.XUnitTest.MediatR.Email +{ + using FluentValidation.TestHelper; + using Streetcode.Email.BLL.DTO; + using Streetcode.Email.BLL.MediatR.Email; + using Xunit; + + public class EmailDTOValidatorTests + { + private readonly EmailDTOValidator validator; + + public EmailDTOValidatorTests() + { + this.validator = new EmailDTOValidator(); + } + + [Fact] + public void ShouldHaveError_WhenEmailIsEmpty() + { + // Arrange + var model = new EmailDTO { From = string.Empty }; + + // Act + var result = this.validator.TestValidate(model); + + // Assert + result.ShouldHaveValidationErrorFor(x => x.From); + } + + [Fact] + public void ShouldHaveError_WhenEmailIsInvalid() + { + // Arrange + var model = new EmailDTO { From = "not-an-email" }; + + // Act + var result = this.validator.TestValidate(model); + + // Assert + result.ShouldHaveValidationErrorFor(x => x.From) + .WithErrorCode("EmailValidator"); + } + + [Fact] + public void ShouldHaveError_WhenMessageIsTooShort() + { + // Arrange + var model = new EmailDTO + { + From = "test@gmail.com", + Content = "123" + }; + + // Act + var result = this.validator.TestValidate(model); + + // Assert + result.ShouldHaveValidationErrorFor(x => x.Content); + } + + [Fact] + public void ShouldHaveError_WhenMessageExceedsMaxLength() + { + // Arrange + var model = new EmailDTO + { + From = "test@gmail.com", + Content = new string('a', 1001) + }; + + // Act + var result = this.validator.TestValidate(model); + + // Assert + result.ShouldHaveValidationErrorFor(x => x.Content); + } + + [Fact] + public void ShouldNotHaveAnyValidationErrors_WhenDTOIsValid() + { + // Arrange + var model = new EmailDTO + { + From = "test@gmail.com", + Content = "Hello World!" + }; + + // Act + var result = this.validator.TestValidate(model); + + // Assert + result.ShouldNotHaveAnyValidationErrors(); + } + } +} \ No newline at end of file diff --git a/Streetcode/Streetcode.Email.XUnitTest/Email/SendEmailHandlerTests.cs b/Streetcode/Streetcode.Email.XUnitTest/Email/SendEmailHandlerTests.cs new file mode 100644 index 0000000..8c18411 --- /dev/null +++ b/Streetcode/Streetcode.Email.XUnitTest/Email/SendEmailHandlerTests.cs @@ -0,0 +1,103 @@ +namespace Streetcode.Email.XUnitTest.MediatR.Email +{ + using AutoMapper; + using FluentAssertions; + using Hangfire; + using Hangfire.Common; + using Hangfire.States; + using Microsoft.EntityFrameworkCore; + using Microsoft.Extensions.Logging; + using Moq; + using Streetcode.Email.BLL.DTO; + using Streetcode.Email.BLL.Interfaces; + using Streetcode.Email.BLL.MediatR.Email; + using Streetcode.Email.DAL.Persistence; + using Streetcode.Email.BLL.Mapping; + using Streetcode.Resources; + public class SendEmailHandlerTests : IDisposable + { + private readonly EmailDbContext dbContext; + private readonly Mock> mockLogger; + private readonly Mock mockBackgroundJob; + private readonly IMapper mapper; + private readonly SendEmailHandler handler; + + public SendEmailHandlerTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + + this.dbContext = new EmailDbContext(options); + this.mockLogger = new Mock>(); + this.mockBackgroundJob = new Mock(); + + var configuration = new MapperConfiguration(cfg => + { + cfg.AddProfile(new EmailProfile()); + }); + this.mapper = new Mapper(configuration); + + this.handler = new SendEmailHandler( + this.dbContext, + this.mapper, + this.mockLogger.Object, + this.mockBackgroundJob.Object); + } + + [Fact] + public async Task Handle_ShouldReturnOk_WhenEmailIsSavedSuccessfully() + { + // Arrange + var EmailDto = new EmailDTO { From = "test@gmail.com", Content = "Valid message" }; + var command = new SendEmailCommand(EmailDto); + + // Act + var result = await this.handler.Handle(command, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + this.mockBackgroundJob.Verify(x => x.Create( + It.Is(j => j.Method.Name == nameof(IEmailService.SendEmailAsync)), + It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_ShouldReturnFail_IfDatabaseSaveFails() + { + // Arrange + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: "FailDatabase") + .Options; + + var mockContext = new Mock(options) { CallBase = true }; + + mockContext + .Setup(c => c.SaveChangesAsync(It.IsAny())) + .ReturnsAsync(0); + + var failHandler = new SendEmailHandler( + mockContext.Object, + this.mapper, + this.mockLogger.Object, + this.mockBackgroundJob.Object); + + var command = new SendEmailCommand(new EmailDTO { From = "fail@test.com", Content = "fail message" }); + + // Act + var result = await failHandler.Handle(command, CancellationToken.None); + + // Assert + result.IsFailed.Should().BeTrue(); + result.Errors.Should().ContainSingle(e => e.Message == Messages.Error_FailedToCreateEntity); + + this.mockBackgroundJob.Verify(x => x.Create(It.IsAny(), It.IsAny()), Times.Never); + } + + public void Dispose() + { + this.dbContext.Database.EnsureDeleted(); + this.dbContext.Dispose(); + } + } +} \ No newline at end of file diff --git a/Streetcode/Streetcode.Email.XUnitTest/Streetcode.Email.XUnitTest.csproj b/Streetcode/Streetcode.Email.XUnitTest/Streetcode.Email.XUnitTest.csproj new file mode 100644 index 0000000..623a4a8 --- /dev/null +++ b/Streetcode/Streetcode.Email.XUnitTest/Streetcode.Email.XUnitTest.csproj @@ -0,0 +1,31 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + + + + diff --git a/Streetcode/Streetcode.Shared/Contracts/IEmailMessage.cs b/Streetcode/Streetcode.Shared/Contracts/IEmailMessage.cs new file mode 100644 index 0000000..a13df68 --- /dev/null +++ b/Streetcode/Streetcode.Shared/Contracts/IEmailMessage.cs @@ -0,0 +1,8 @@ +namespace Streetcode.Shared.Contracts +{ + public interface IEmailMessage + { + public string From { get; } + public string Content { get; } + } +} diff --git a/Streetcode/Streetcode.WebApi/Controllers/Email/EmailController.cs b/Streetcode/Streetcode.WebApi/Controllers/Email/EmailController.cs index e85773a..e6c1192 100644 --- a/Streetcode/Streetcode.WebApi/Controllers/Email/EmailController.cs +++ b/Streetcode/Streetcode.WebApi/Controllers/Email/EmailController.cs @@ -1,17 +1,32 @@ +using MassTransit; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Streetcode.BLL.DTO.Email; -using Streetcode.BLL.MediatR.Email; +using Streetcode.Shared.Contracts; namespace Streetcode.WebApi.Controllers.Email { public class EmailController : BaseApiController { + private readonly IPublishEndpoint _publishEndpoint; + + public EmailController(IPublishEndpoint publishEndpoint) + { + _publishEndpoint = publishEndpoint; + } + [HttpPost] [AllowAnonymous] public async Task Send([FromBody] EmailDTO email) { - return HandleResult(await Mediator.Send(new SendEmailCommand(email))); + await _publishEndpoint.Publish( + new + { + email.From, + email.Content + }); + + return Accepted(); } } } diff --git a/Streetcode/Streetcode.WebApi/Extensions/ServiceCollectionExtensions.cs b/Streetcode/Streetcode.WebApi/Extensions/ServiceCollectionExtensions.cs index d48a9ae..6e75186 100644 --- a/Streetcode/Streetcode.WebApi/Extensions/ServiceCollectionExtensions.cs +++ b/Streetcode/Streetcode.WebApi/Extensions/ServiceCollectionExtensions.cs @@ -10,7 +10,6 @@ using Microsoft.OpenApi.Models; using Streetcode.BLL.Interfaces.BlobStorage; using Streetcode.BLL.Interfaces.Cache; -using Streetcode.BLL.Interfaces.Email; using Streetcode.BLL.Interfaces.Instagram; using Streetcode.BLL.Interfaces.Logging; using Streetcode.BLL.Interfaces.Payment; @@ -18,7 +17,6 @@ using Streetcode.BLL.MediatR.PipelineBehavior; using Streetcode.BLL.Services.BlobStorageService; using Streetcode.BLL.Services.Cache; -using Streetcode.BLL.Services.Email; using Streetcode.BLL.Services.Instagram; using Streetcode.BLL.Services.Logging; using Streetcode.BLL.Services.Payment; @@ -51,7 +49,6 @@ public static void AddCustomServices(this IServiceCollection services, IConfigur services.AddScoped(); services.AddScoped(); - services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/Streetcode/Streetcode.sln b/Streetcode/Streetcode.sln index 17164d8..fa08c0a 100644 --- a/Streetcode/Streetcode.sln +++ b/Streetcode/Streetcode.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.14.36717.8 d17.14 +# Visual Studio Version 18 +VisualStudioVersion = 18.3.11512.155 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Streetcode.WebApi", "Streetcode.WebApi\Streetcode.WebApi.csproj", "{CAA32FB4-F481-4748-BFCE-33B0DBF433E8}" EndProject @@ -20,6 +20,8 @@ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{95E863CE-C3B4-4DBC-8C15-957E63BA45B6}" ProjectSection(SolutionItems) = preProject .editorconfig = .editorconfig + .env = .env + docker-compose.yml = docker-compose.yml EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Services", "Services", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" @@ -40,6 +42,16 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Streetcode.Auth.XUnitTest", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Streetcode.Resources", "Streetcode.Resources\Streetcode.Resources.csproj", "{42EC9582-F9D7-9F18-42AB-C0D335E17C5C}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Streetcode.Email", "Streetcode.Email", "{722B38C8-AA92-4391-8320-9CCBFCD6BBEF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Streetcode.Email.WebAPI", "Streetcode.Email.WebAPI\Streetcode.Email.WebAPI.csproj", "{F3953CBD-E8CE-40AA-A219-DED4518B6334}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Streetcode.Email.BLL", "Streetcode.Email.BLL\Streetcode.Email.BLL.csproj", "{CC733440-BA59-431D-B9CC-BB2B7EC0091B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Streetcode.Email.DAL", "Streetcode.Email.DAL\Streetcode.Email.DAL.csproj", "{072870A2-8064-40E2-B5C5-BF90B33F36BC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Streetcode.Email.XUnitTest", "Streetcode.Email.XUnitTest\Streetcode.Email.XUnitTest.csproj", "{360844AB-D2B0-49B1-A500-EAFC0F8A7659}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -96,6 +108,22 @@ Global {42EC9582-F9D7-9F18-42AB-C0D335E17C5C}.Debug|Any CPU.Build.0 = Debug|Any CPU {42EC9582-F9D7-9F18-42AB-C0D335E17C5C}.Release|Any CPU.ActiveCfg = Release|Any CPU {42EC9582-F9D7-9F18-42AB-C0D335E17C5C}.Release|Any CPU.Build.0 = Release|Any CPU + {F3953CBD-E8CE-40AA-A219-DED4518B6334}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F3953CBD-E8CE-40AA-A219-DED4518B6334}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F3953CBD-E8CE-40AA-A219-DED4518B6334}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F3953CBD-E8CE-40AA-A219-DED4518B6334}.Release|Any CPU.Build.0 = Release|Any CPU + {CC733440-BA59-431D-B9CC-BB2B7EC0091B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CC733440-BA59-431D-B9CC-BB2B7EC0091B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CC733440-BA59-431D-B9CC-BB2B7EC0091B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CC733440-BA59-431D-B9CC-BB2B7EC0091B}.Release|Any CPU.Build.0 = Release|Any CPU + {072870A2-8064-40E2-B5C5-BF90B33F36BC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {072870A2-8064-40E2-B5C5-BF90B33F36BC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {072870A2-8064-40E2-B5C5-BF90B33F36BC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {072870A2-8064-40E2-B5C5-BF90B33F36BC}.Release|Any CPU.Build.0 = Release|Any CPU + {360844AB-D2B0-49B1-A500-EAFC0F8A7659}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {360844AB-D2B0-49B1-A500-EAFC0F8A7659}.Debug|Any CPU.Build.0 = Debug|Any CPU + {360844AB-D2B0-49B1-A500-EAFC0F8A7659}.Release|Any CPU.ActiveCfg = Release|Any CPU + {360844AB-D2B0-49B1-A500-EAFC0F8A7659}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -107,6 +135,11 @@ Global {D89E738A-A455-4D17-9C9D-581AE177E5AC} = {F69E76E4-84F5-4D70-A52B-E588CCEE716B} {00D85447-2B32-4B68-A4C2-1B467C42FD16} = {7F5CBCE1-AD14-4C06-8939-B595C1B25B47} {DF36FE52-8788-4490-8406-62132A7E6756} = {F69E76E4-84F5-4D70-A52B-E588CCEE716B} + {722B38C8-AA92-4391-8320-9CCBFCD6BBEF} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {F3953CBD-E8CE-40AA-A219-DED4518B6334} = {722B38C8-AA92-4391-8320-9CCBFCD6BBEF} + {CC733440-BA59-431D-B9CC-BB2B7EC0091B} = {722B38C8-AA92-4391-8320-9CCBFCD6BBEF} + {072870A2-8064-40E2-B5C5-BF90B33F36BC} = {722B38C8-AA92-4391-8320-9CCBFCD6BBEF} + {360844AB-D2B0-49B1-A500-EAFC0F8A7659} = {722B38C8-AA92-4391-8320-9CCBFCD6BBEF} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {7D3D1FEF-DB8F-4A51-AD6E-0EE327AB534A} diff --git a/Streetcode/docker-compose.yml b/Streetcode/docker-compose.yml new file mode 100644 index 0000000..bf2c125 --- /dev/null +++ b/Streetcode/docker-compose.yml @@ -0,0 +1,34 @@ +version: '3.8' + +services: + rabbitmq: + image: rabbitmq:3-management + container_name: streetcode-rabbitmq + restart: always + ports: + - "5672:5672" + - "15672:15672" + environment: + - RABBITMQ_DEFAULT_USER=${RABBITMQ_DEFAULT_USER} + - RABBITMQ_DEFAULT_PASS=${RABBITMQ_DEFAULT_PASS} + + redis: + image: redis:latest + container_name: streetcode-redis + restart: always + ports: + - "6379:6379" + + sqlserver: + image: mcr.microsoft.com/mssql/server:2022-latest + container_name: streetcode-main-db + ports: + - "1433:1433" + environment: + - ACCEPT_EULA=Y + - MSSQL_SA_PASSWORD=${DB_PASSWORD} + healthcheck: + test: ["CMD", "/opt/mssql-tools/bin/sqlcmd", "-U", "sa", "-P", "${DB_PASSWORD}", "-Q", "SELECT 1"] + interval: 10s + timeout: 3s + retries: 10 \ No newline at end of file