Add contact feature
This commit is contained in:
@@ -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
|
||||
{
|
||||
}
|
||||
@@ -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>
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
using MediatR;
|
||||
|
||||
namespace BeauFindlay.Api.Abstractions.Messaging;
|
||||
|
||||
public interface IDomainEvent : INotification
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using BeauFindlay.Shared.Abstractions;
|
||||
using MediatR;
|
||||
|
||||
namespace BeauFindlay.Api.Abstractions.Messaging;
|
||||
|
||||
public interface IQuery<TResponse> : IRequest<Result<TResponse>>
|
||||
{
|
||||
}
|
||||
@@ -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>
|
||||
{
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using BeauFindlay.Shared.Abstractions;
|
||||
|
||||
namespace BeauFindlay.Api.Features.Contact;
|
||||
|
||||
internal interface IRecaptchaService
|
||||
{
|
||||
Task<Result> ValidateResponseAsync(string recaptchaResponse,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace BeauFindlay.Api.Features.Contact;
|
||||
|
||||
internal sealed record RecaptchaSettings(string ApiKey);
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user