Add contact feature

This commit is contained in:
2024-03-09 09:47:49 +00:00
parent 78bef4c113
commit a00a8d11ac
33 changed files with 1126 additions and 44 deletions

View File

@@ -0,0 +1,16 @@
using BeauFindlay.Shared.Abstractions;
using MediatR;
namespace BeauFindlay.Api.Abstractions.Messaging;
public interface ICommand : IRequest<Result>, IBaseCommand
{
}
public interface ICommand<TReponse> : IRequest<Result<TReponse>>, IBaseCommand
{
}
public interface IBaseCommand
{
}

View File

@@ -0,0 +1,14 @@
using BeauFindlay.Shared.Abstractions;
using MediatR;
namespace BeauFindlay.Api.Abstractions.Messaging;
public interface ICommandHandler<TCommand> : IRequestHandler<TCommand, Result>
where TCommand : ICommand
{
}
public interface ICommandHandler<TCommand, TResponse> : IRequestHandler<TCommand, Result<TResponse>>
where TCommand : ICommand<TResponse>
{
}

View File

@@ -0,0 +1,7 @@
using MediatR;
namespace BeauFindlay.Api.Abstractions.Messaging;
public interface IDomainEvent : INotification
{
}

View File

@@ -0,0 +1,8 @@
using BeauFindlay.Shared.Abstractions;
using MediatR;
namespace BeauFindlay.Api.Abstractions.Messaging;
public interface IQuery<TResponse> : IRequest<Result<TResponse>>
{
}

View File

@@ -0,0 +1,9 @@
using BeauFindlay.Shared.Abstractions;
using MediatR;
namespace BeauFindlay.Api.Abstractions.Messaging;
public interface IQueryHandler<TQuery, TResponse> : IRequestHandler<TQuery, Result<TResponse>>
where TQuery : IQuery<TResponse>
{
}

View File

@@ -7,22 +7,29 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.9.0" />
<PackageReference Include="MediatR" Version="12.2.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker" Version="1.20.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http" Version="3.1.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" Version="1.16.2" />
<PackageReference Include="Microsoft.ApplicationInsights.WorkerService" Version="2.21.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.ApplicationInsights" Version="1.0.0" />
<PackageReference Include="SendGrid" Version="9.29.2" />
<PackageReference Include="SendGrid.Extensions.DependencyInjection" Version="1.0.1" />
</ItemGroup>
<ItemGroup>
<None Update="host.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="local.settings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
<CopyToPublishDirectory>Never</CopyToPublishDirectory>
</None>
</ItemGroup>
<ItemGroup>
<Using Include="System.Threading.ExecutionContext" Alias="ExecutionContext"/>
<Using Include="System.Threading.ExecutionContext" Alias="ExecutionContext" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\BeauFindlay.Shared\BeauFindlay.Shared.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,45 @@
using BeauFindlay.Api.Features.Contact;
using FluentValidation;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using SendGrid.Extensions.DependencyInjection;
namespace BeauFindlay.Api.Extensions;
public static class ServiceCollectionExtensions
{
public static void AddApplicationServices(this IServiceCollection services)
{
var assembly = typeof(ServiceCollectionExtensions).Assembly;
services.AddMediatR(config => { config.RegisterServicesFromAssembly(assembly); });
services.AddValidatorsFromAssembly(assembly, includeInternalTypes: true);
services.AddEmailService();
services.AddRecaptchaService();
}
private static void AddEmailService(this IServiceCollection services)
{
var apiKey = Environment.GetEnvironmentVariable("SendGridApiKey")
?? throw new ArgumentException("SendGrid API key cannot be null");
services.AddSendGrid(config => config.ApiKey = apiKey);
services.AddScoped<ISendGridService, SendGridService>();
}
private static void AddRecaptchaService(this IServiceCollection services)
{
var apiKey = Environment.GetEnvironmentVariable("RecaptchaApiKey")
?? throw new ArgumentException("Google Recaptcha API key cannot be null");
var settings = new RecaptchaSettings(apiKey);
services.AddSingleton(settings);
services.AddHttpClient<IRecaptchaService, RecaptchaService>();
}
}

View File

@@ -0,0 +1,9 @@
using BeauFindlay.Shared.Abstractions;
namespace BeauFindlay.Api.Features.Contact;
internal interface IRecaptchaService
{
Task<Result> ValidateResponseAsync(string recaptchaResponse,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,9 @@
using BeauFindlay.Shared.Abstractions;
namespace BeauFindlay.Api.Features.Contact;
internal interface ISendGridService
{
Task<Result> SendEmailAsync(string from, string to, string subject, string plainTextContent,
string htmlContent);
}

View File

@@ -0,0 +1,93 @@
using BeauFindlay.Shared.Abstractions;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace BeauFindlay.Api.Features.Contact;
internal sealed class RecaptchaService(
HttpClient httpClient,
ILogger<RecaptchaService> logger,
RecaptchaSettings settings)
: IRecaptchaService
{
public async Task<Result> ValidateResponseAsync(string recaptchaResponse,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(recaptchaResponse))
{
logger.LogWarning("Recaptcha response is null.");
return Result.Failure(RecaptchaErrors.ResponseNull);
}
var response = await httpClient.PostAsync(
$"https://www.google.com/recaptcha/api/siteverify?secret={settings.ApiKey}&response={recaptchaResponse}",
null, cancellationToken);
if (!response.IsSuccessStatusCode)
{
logger.LogError(
"Google Recaptcha API validation request failed. Code: {StatusCode}",
response.StatusCode);
return Result.Failure(RecaptchaErrors.ApiRequestFailed);
}
var responseString = await response.Content.ReadAsStringAsync(cancellationToken);
var recaptchaResult = JsonConvert.DeserializeObject<RecaptchaVerificationResult>(responseString);
if (recaptchaResult is null)
{
logger.LogError("Unable to deserialize Recaptcha result.");
return Result.Failure(RecaptchaErrors.ResponseSerializationFailed);
}
if (!recaptchaResult.Success)
{
logger.LogWarning(
"Google Recaptcha validation failed. Errors: {Errors}",
recaptchaResult.ErrorCodes);
return Result.Failure(RecaptchaErrors.ValidationFailed);
}
logger.LogInformation("Recaptcha validation passed.");
return Result.Success();
}
private class RecaptchaVerificationResult
{
[JsonProperty("success")]
public bool Success { get; set; }
[JsonProperty("challenge_ts")]
public DateTime ChallengeTs { get; set; }
[JsonProperty("hostname")]
public string Hostname { get; set; } = string.Empty;
[JsonProperty("error-codes")]
public List<string> ErrorCodes { get; set; } = [];
}
}
public static class RecaptchaErrors
{
public static readonly Error ResponseNull = new(
"Recaptcha.ResponseNull",
"Recaptcha response is null.");
public static readonly Error ValidationFailed = new(
"Recaptcha.ValidationFailed",
"Recaptcha validation failed.");
public static readonly Error ResponseSerializationFailed = new(
"Recaptcha.ResponseSerializationFailed",
"Unable to deserialize Recaptcha result.");
public static readonly Error ApiRequestFailed = new(
"Recaptcha.ApiRequestFailed",
"Recaptcha API validation request failed.");
}

View File

@@ -0,0 +1,3 @@
namespace BeauFindlay.Api.Features.Contact;
internal sealed record RecaptchaSettings(string ApiKey);

View File

@@ -0,0 +1,71 @@
using BeauFindlay.Api.Abstractions.Messaging;
using BeauFindlay.Shared.Abstractions;
using FluentValidation;
using Microsoft.Extensions.Logging;
namespace BeauFindlay.Api.Features.Contact;
public sealed record SendContactEmailCommand(string Name, string FromEmail, string Message, string RecaptchaResponse)
: ICommand;
internal sealed class SendContactEmailCommandValidator : AbstractValidator<SendContactEmailCommand>
{
public SendContactEmailCommandValidator(IRecaptchaService recaptchaService)
{
RuleFor(c => c.Name)
.NotEmpty()
.MaximumLength(50);
RuleFor(c => c.FromEmail)
.NotEmpty()
.EmailAddress();
RuleFor(c => c.Message)
.NotEmpty()
.MaximumLength(500);
RuleFor(c => c.RecaptchaResponse)
.NotEmpty()
.MustAsync(async (response, cancellation) =>
{
var validationResult = await recaptchaService.ValidateResponseAsync(response, cancellation);
return validationResult.IsSuccess;
});
}
}
internal sealed class SendContactEmailCommandHandler(
ISendGridService sendGridService,
IValidator<SendContactEmailCommand> validator,
ILogger<SendContactEmailCommandHandler> logger)
: ICommandHandler<SendContactEmailCommand>
{
private const string EmailSubjectBase = "New website enquiry";
private const string MyEmail = "me@beaufindlay.com";
public async Task<Result> Handle(SendContactEmailCommand request, CancellationToken cancellationToken)
{
var validationResult = await validator.ValidateAsync(request, cancellationToken);
if (!validationResult.IsValid)
{
logger.LogError("Command validation failed. Errors: {ValidationErrors}", validationResult.ToString());
return Result.Failure(new Error("ValidationFailed", "Command validation failed"));
}
var subject = $"{EmailSubjectBase} - {request.FromEmail}";
var message = $"From: {request.FromEmail}. Message: {request.Message}";
var htmlMessage = $"<p><b>From:</b> {request.FromEmail}</p> <p><b>Message:</b> <br /> {request.Message}</p>";
var emailResult = await sendGridService.SendEmailAsync(
MyEmail,
MyEmail,
subject,
message,
htmlMessage);
return emailResult.IsFailure ? Result.Failure(emailResult.Error) : Result.Success();
}
}

View File

@@ -0,0 +1,76 @@
using System.Collections.Generic;
using System.Net;
using System.Security.Cryptography;
using BeauFindlay.Shared.Contracts;
using MediatR;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace BeauFindlay.Api.Features.Contact;
public class SendContactEmailFunction(ILoggerFactory loggerFactory, ISender sender)
{
private readonly ILogger logger = loggerFactory.CreateLogger<SendContactEmailFunction>();
[Function(nameof(SendContactEmailFunction))]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Function, "post", Route = "send-contact-email")] HttpRequestData req,
FunctionContext executionContext, CancellationToken cancellationToken)
{
logger.LogInformation($"{nameof(SendContactEmailFunction)} function received a request.");
var requestBody = await new StreamReader(req.Body).ReadToEndAsync(cancellationToken);
var request = JsonConvert.DeserializeObject<SendContactEmailRequest>(requestBody);
HttpResponseData response;
try
{
if (request == null)
{
throw new ApplicationException("Unable to deserialize response.");
}
var emailCommand = new SendContactEmailCommand(
request.Name,
request.FromEmail,
request.Message,
request.RecaptchaResponse);
var sendEmailResult = await sender.Send(emailCommand, cancellationToken);
if (sendEmailResult.IsFailure)
{
logger.LogError("Send email command failed. Error: {Error}", sendEmailResult.Error.Message);
response = req.CreateResponse(HttpStatusCode.BadRequest);
var error = new ErrorResponse
{
Code = (int)HttpStatusCode.BadRequest,
Message = sendEmailResult.Error.Message
};
await response.WriteAsJsonAsync(error, cancellationToken);
}
else
{
response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync("", cancellationToken: cancellationToken);
}
}
catch (Exception e)
{
logger.LogError(e, "Exception occured. Error: '{Message}'", e.Message);
response = req.CreateResponse(HttpStatusCode.InternalServerError);
await response.WriteAsJsonAsync(ErrorResponse.Generic, cancellationToken);
}
return response;
}
}

View File

@@ -0,0 +1,38 @@
using BeauFindlay.Shared.Abstractions;
using Microsoft.Extensions.Logging;
using SendGrid;
using SendGrid.Helpers.Mail;
namespace BeauFindlay.Api.Features.Contact;
internal sealed class SendGridService(ISendGridClient sendGridClient, ILogger<SendGridService> logger)
: ISendGridService
{
public async Task<Result> SendEmailAsync(string from, string to, string subject, string plainTextContent,
string htmlContent)
{
ArgumentException.ThrowIfNullOrWhiteSpace(from, nameof(from));
ArgumentException.ThrowIfNullOrWhiteSpace(to, nameof(to));
ArgumentException.ThrowIfNullOrWhiteSpace(subject, nameof(subject));
ArgumentException.ThrowIfNullOrWhiteSpace(plainTextContent, nameof(plainTextContent));
ArgumentException.ThrowIfNullOrWhiteSpace(htmlContent, nameof(htmlContent));
var fromEmail = new EmailAddress(from);
var toEmail = new EmailAddress(to);
var message = MailHelper.CreateSingleEmail(fromEmail, toEmail, subject, plainTextContent, htmlContent);
var response = await sendGridClient.SendEmailAsync(message);
if (response is not { IsSuccessStatusCode: true })
{
logger.LogError("Failed to send email. Status code: '{StatusCode}'", response?.StatusCode);
return Result.Failure(new Error("Email.SendFailed", "Failed to send email."));
}
logger.LogInformation("Email sent successfully.");
return Result.Success();
}
}

View File

@@ -1,3 +1,4 @@
using BeauFindlay.Api.Extensions;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
@@ -8,6 +9,7 @@ var host = new HostBuilder()
{
services.AddApplicationInsightsTelemetryWorkerService();
services.ConfigureFunctionsApplicationInsights();
services.AddApplicationServices();
})
.Build();