diff --git a/.gitignore b/.gitignore index 84ddab3..fb28580 100644 --- a/.gitignore +++ b/.gitignore @@ -398,3 +398,7 @@ FodyWeavers.xsd *.sln.iml /BeauFindlay/BeauFindlay/package-lock.json /BeauFindlay/BeauFindlay/package.json +/BeauFindlay/src/BeauFindlay.Client/package-lock.json +/BeauFindlay/src/BeauFindlay.Client/package.json +/BeauFindlay/src/BeauFindlay.Client/package-lock.json +/BeauFindlay/src/BeauFindlay.Client/package.json diff --git a/BeauFindlay/BeauFindlay.sln b/BeauFindlay/BeauFindlay.sln index 5bef8ea..ce28891 100644 --- a/BeauFindlay/BeauFindlay.sln +++ b/BeauFindlay/BeauFindlay.sln @@ -3,7 +3,13 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.9.34622.214 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BeauFindlay", "BeauFindlay\BeauFindlay.csproj", "{F6AA110B-9F12-441F-858C-AE476CB9B107}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{3407557D-A21B-4F48-930C-6FDCE961ED2A}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BeauFindlay.Client", "src\BeauFindlay.Client\BeauFindlay.Client.csproj", "{979CCAA2-5F1B-457B-9536-02F94BC98F9F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BeauFindlay.Api", "src\BeauFindlay.Api\BeauFindlay.Api.csproj", "{D2F248BF-8487-4CE7-B6AA-D558587A52DA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BeauFindlay.Shared", "src\BeauFindlay.Shared\BeauFindlay.Shared.csproj", "{0A17E6ED-1B40-4FAE-94D5-1255C3569F6E}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -11,14 +17,27 @@ Global Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {F6AA110B-9F12-441F-858C-AE476CB9B107}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {F6AA110B-9F12-441F-858C-AE476CB9B107}.Debug|Any CPU.Build.0 = Debug|Any CPU - {F6AA110B-9F12-441F-858C-AE476CB9B107}.Release|Any CPU.ActiveCfg = Release|Any CPU - {F6AA110B-9F12-441F-858C-AE476CB9B107}.Release|Any CPU.Build.0 = Release|Any CPU + {979CCAA2-5F1B-457B-9536-02F94BC98F9F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {979CCAA2-5F1B-457B-9536-02F94BC98F9F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {979CCAA2-5F1B-457B-9536-02F94BC98F9F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {979CCAA2-5F1B-457B-9536-02F94BC98F9F}.Release|Any CPU.Build.0 = Release|Any CPU + {D2F248BF-8487-4CE7-B6AA-D558587A52DA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D2F248BF-8487-4CE7-B6AA-D558587A52DA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D2F248BF-8487-4CE7-B6AA-D558587A52DA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D2F248BF-8487-4CE7-B6AA-D558587A52DA}.Release|Any CPU.Build.0 = Release|Any CPU + {0A17E6ED-1B40-4FAE-94D5-1255C3569F6E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0A17E6ED-1B40-4FAE-94D5-1255C3569F6E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0A17E6ED-1B40-4FAE-94D5-1255C3569F6E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0A17E6ED-1B40-4FAE-94D5-1255C3569F6E}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {979CCAA2-5F1B-457B-9536-02F94BC98F9F} = {3407557D-A21B-4F48-930C-6FDCE961ED2A} + {D2F248BF-8487-4CE7-B6AA-D558587A52DA} = {3407557D-A21B-4F48-930C-6FDCE961ED2A} + {0A17E6ED-1B40-4FAE-94D5-1255C3569F6E} = {3407557D-A21B-4F48-930C-6FDCE961ED2A} + EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {6D05FF39-4D7B-4F0D-8136-E48F71106C3A} EndGlobalSection diff --git a/BeauFindlay/src/BeauFindlay.Api/.gitignore b/BeauFindlay/src/BeauFindlay.Api/.gitignore new file mode 100644 index 0000000..ff5b00c --- /dev/null +++ b/BeauFindlay/src/BeauFindlay.Api/.gitignore @@ -0,0 +1,264 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# Azure Functions localsettings file +local.settings.json + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ + +# Visual Studio 2015 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# DNX +project.lock.json +project.fragment.lock.json +artifacts/ + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# TODO: Comment the next line if you want to checkin your web deploy settings +# but database connection strings (with potential passwords) will be unencrypted +#*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/packages/* +# except build/, which is used as an MSBuild target. +!**/packages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/packages/repositories.config +# NuGet v3's project.json files produces more ignoreable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +node_modules/ +orleans.codegen.cs + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +*.mdf +*.ldf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# JetBrains Rider +.idea/ +*.sln.iml + +# CodeRush +.cr/ + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc \ No newline at end of file diff --git a/BeauFindlay/src/BeauFindlay.Api/Abstractions/Messaging/ICommand.cs b/BeauFindlay/src/BeauFindlay.Api/Abstractions/Messaging/ICommand.cs new file mode 100644 index 0000000..044f012 --- /dev/null +++ b/BeauFindlay/src/BeauFindlay.Api/Abstractions/Messaging/ICommand.cs @@ -0,0 +1,16 @@ +using BeauFindlay.Shared.Abstractions; +using MediatR; + +namespace BeauFindlay.Api.Abstractions.Messaging; + +public interface ICommand : IRequest, IBaseCommand +{ +} + +public interface ICommand : IRequest>, IBaseCommand +{ +} + +public interface IBaseCommand +{ +} \ No newline at end of file diff --git a/BeauFindlay/src/BeauFindlay.Api/Abstractions/Messaging/ICommandHandler.cs b/BeauFindlay/src/BeauFindlay.Api/Abstractions/Messaging/ICommandHandler.cs new file mode 100644 index 0000000..ac60f74 --- /dev/null +++ b/BeauFindlay/src/BeauFindlay.Api/Abstractions/Messaging/ICommandHandler.cs @@ -0,0 +1,14 @@ +using BeauFindlay.Shared.Abstractions; +using MediatR; + +namespace BeauFindlay.Api.Abstractions.Messaging; + +public interface ICommandHandler : IRequestHandler + where TCommand : ICommand +{ +} + +public interface ICommandHandler : IRequestHandler> + where TCommand : ICommand +{ +} \ No newline at end of file diff --git a/BeauFindlay/src/BeauFindlay.Api/Abstractions/Messaging/IDomainEvent.cs b/BeauFindlay/src/BeauFindlay.Api/Abstractions/Messaging/IDomainEvent.cs new file mode 100644 index 0000000..e2dc074 --- /dev/null +++ b/BeauFindlay/src/BeauFindlay.Api/Abstractions/Messaging/IDomainEvent.cs @@ -0,0 +1,7 @@ +using MediatR; + +namespace BeauFindlay.Api.Abstractions.Messaging; + +public interface IDomainEvent : INotification +{ +} \ No newline at end of file diff --git a/BeauFindlay/src/BeauFindlay.Api/Abstractions/Messaging/IQuery.cs b/BeauFindlay/src/BeauFindlay.Api/Abstractions/Messaging/IQuery.cs new file mode 100644 index 0000000..5c15bbc --- /dev/null +++ b/BeauFindlay/src/BeauFindlay.Api/Abstractions/Messaging/IQuery.cs @@ -0,0 +1,8 @@ +using BeauFindlay.Shared.Abstractions; +using MediatR; + +namespace BeauFindlay.Api.Abstractions.Messaging; + +public interface IQuery : IRequest> +{ +} \ No newline at end of file diff --git a/BeauFindlay/src/BeauFindlay.Api/Abstractions/Messaging/IQueryHandler.cs b/BeauFindlay/src/BeauFindlay.Api/Abstractions/Messaging/IQueryHandler.cs new file mode 100644 index 0000000..384b1bf --- /dev/null +++ b/BeauFindlay/src/BeauFindlay.Api/Abstractions/Messaging/IQueryHandler.cs @@ -0,0 +1,9 @@ +using BeauFindlay.Shared.Abstractions; +using MediatR; + +namespace BeauFindlay.Api.Abstractions.Messaging; + +public interface IQueryHandler : IRequestHandler> + where TQuery : IQuery +{ +} \ No newline at end of file diff --git a/BeauFindlay/src/BeauFindlay.Api/BeauFindlay.Api.csproj b/BeauFindlay/src/BeauFindlay.Api/BeauFindlay.Api.csproj new file mode 100644 index 0000000..c2f7ea6 --- /dev/null +++ b/BeauFindlay/src/BeauFindlay.Api/BeauFindlay.Api.csproj @@ -0,0 +1,35 @@ + + + net8.0 + v4 + Exe + enable + enable + + + + + + + + + + + + + + + PreserveNewest + + + Always + Never + + + + + + + + + \ No newline at end of file diff --git a/BeauFindlay/src/BeauFindlay.Api/Extensions/ServiceCollectionExtensions.cs b/BeauFindlay/src/BeauFindlay.Api/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..0768bbc --- /dev/null +++ b/BeauFindlay/src/BeauFindlay.Api/Extensions/ServiceCollectionExtensions.cs @@ -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(); + } + + 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(); + } +} \ No newline at end of file diff --git a/BeauFindlay/src/BeauFindlay.Api/Features/Contact/IRecaptchaService.cs b/BeauFindlay/src/BeauFindlay.Api/Features/Contact/IRecaptchaService.cs new file mode 100644 index 0000000..8f9cd12 --- /dev/null +++ b/BeauFindlay/src/BeauFindlay.Api/Features/Contact/IRecaptchaService.cs @@ -0,0 +1,9 @@ +using BeauFindlay.Shared.Abstractions; + +namespace BeauFindlay.Api.Features.Contact; + +internal interface IRecaptchaService +{ + Task ValidateResponseAsync(string recaptchaResponse, + CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/BeauFindlay/src/BeauFindlay.Api/Features/Contact/ISendGridService.cs b/BeauFindlay/src/BeauFindlay.Api/Features/Contact/ISendGridService.cs new file mode 100644 index 0000000..b0f5b04 --- /dev/null +++ b/BeauFindlay/src/BeauFindlay.Api/Features/Contact/ISendGridService.cs @@ -0,0 +1,9 @@ +using BeauFindlay.Shared.Abstractions; + +namespace BeauFindlay.Api.Features.Contact; + +internal interface ISendGridService +{ + Task SendEmailAsync(string from, string to, string subject, string plainTextContent, + string htmlContent); +} \ No newline at end of file diff --git a/BeauFindlay/src/BeauFindlay.Api/Features/Contact/RecaptchaService.cs b/BeauFindlay/src/BeauFindlay.Api/Features/Contact/RecaptchaService.cs new file mode 100644 index 0000000..990d748 --- /dev/null +++ b/BeauFindlay/src/BeauFindlay.Api/Features/Contact/RecaptchaService.cs @@ -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 logger, + RecaptchaSettings settings) + : IRecaptchaService +{ + public async Task 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(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 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."); +} \ No newline at end of file diff --git a/BeauFindlay/src/BeauFindlay.Api/Features/Contact/RecaptchaSettings.cs b/BeauFindlay/src/BeauFindlay.Api/Features/Contact/RecaptchaSettings.cs new file mode 100644 index 0000000..5f9be6b --- /dev/null +++ b/BeauFindlay/src/BeauFindlay.Api/Features/Contact/RecaptchaSettings.cs @@ -0,0 +1,3 @@ +namespace BeauFindlay.Api.Features.Contact; + +internal sealed record RecaptchaSettings(string ApiKey); \ No newline at end of file diff --git a/BeauFindlay/src/BeauFindlay.Api/Features/Contact/SendContactEmail.cs b/BeauFindlay/src/BeauFindlay.Api/Features/Contact/SendContactEmail.cs new file mode 100644 index 0000000..aca6732 --- /dev/null +++ b/BeauFindlay/src/BeauFindlay.Api/Features/Contact/SendContactEmail.cs @@ -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 +{ + 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 validator, + ILogger logger) + : ICommandHandler +{ + private const string EmailSubjectBase = "New website enquiry"; + private const string MyEmail = "me@beaufindlay.com"; + + public async Task 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 = $"

From: {request.FromEmail}

Message:
{request.Message}

"; + + var emailResult = await sendGridService.SendEmailAsync( + MyEmail, + MyEmail, + subject, + message, + htmlMessage); + + return emailResult.IsFailure ? Result.Failure(emailResult.Error) : Result.Success(); + } +} \ No newline at end of file diff --git a/BeauFindlay/src/BeauFindlay.Api/Features/Contact/SendContactEmailFunction.cs b/BeauFindlay/src/BeauFindlay.Api/Features/Contact/SendContactEmailFunction.cs new file mode 100644 index 0000000..bf2a881 --- /dev/null +++ b/BeauFindlay/src/BeauFindlay.Api/Features/Contact/SendContactEmailFunction.cs @@ -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(); + + [Function(nameof(SendContactEmailFunction))] + public async Task 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(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; + } +} \ No newline at end of file diff --git a/BeauFindlay/src/BeauFindlay.Api/Features/Contact/SendGridService.cs b/BeauFindlay/src/BeauFindlay.Api/Features/Contact/SendGridService.cs new file mode 100644 index 0000000..ca493fc --- /dev/null +++ b/BeauFindlay/src/BeauFindlay.Api/Features/Contact/SendGridService.cs @@ -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 logger) + : ISendGridService +{ + public async Task 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(); + } +} \ No newline at end of file diff --git a/BeauFindlay/src/BeauFindlay.Api/Function1.cs b/BeauFindlay/src/BeauFindlay.Api/Function1.cs new file mode 100644 index 0000000..9260290 --- /dev/null +++ b/BeauFindlay/src/BeauFindlay.Api/Function1.cs @@ -0,0 +1,25 @@ +using System.Net; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; + +namespace BeauFindlay.Api +{ + public class Function1(ILoggerFactory loggerFactory) + { + private readonly ILogger _logger = loggerFactory.CreateLogger(); + + [Function("Function1")] + public HttpResponseData Run([HttpTrigger(AuthorizationLevel.Function, "get", "post")] HttpRequestData req) + { + _logger.LogInformation("C# HTTP trigger function processed a request."); + + var response = req.CreateResponse(HttpStatusCode.OK); + response.Headers.Add("Content-Type", "text/plain; charset=utf-8"); + + response.WriteString("Welcome to Azure Functions!"); + + return response; + } + } +} diff --git a/BeauFindlay/src/BeauFindlay.Api/Program.cs b/BeauFindlay/src/BeauFindlay.Api/Program.cs new file mode 100644 index 0000000..3103e93 --- /dev/null +++ b/BeauFindlay/src/BeauFindlay.Api/Program.cs @@ -0,0 +1,16 @@ +using BeauFindlay.Api.Extensions; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +var host = new HostBuilder() + .ConfigureFunctionsWorkerDefaults() + .ConfigureServices(services => + { + services.AddApplicationInsightsTelemetryWorkerService(); + services.ConfigureFunctionsApplicationInsights(); + services.AddApplicationServices(); + }) + .Build(); + +host.Run(); diff --git a/BeauFindlay/src/BeauFindlay.Api/Properties/launchSettings.json b/BeauFindlay/src/BeauFindlay.Api/Properties/launchSettings.json new file mode 100644 index 0000000..e296acb --- /dev/null +++ b/BeauFindlay/src/BeauFindlay.Api/Properties/launchSettings.json @@ -0,0 +1,9 @@ +{ + "profiles": { + "BeauFindlay.Api": { + "commandName": "Project", + "commandLineArgs": "--port 7197", + "launchBrowser": false + } + } +} \ No newline at end of file diff --git a/BeauFindlay/src/BeauFindlay.Api/Properties/serviceDependencies.json b/BeauFindlay/src/BeauFindlay.Api/Properties/serviceDependencies.json new file mode 100644 index 0000000..df4dcc9 --- /dev/null +++ b/BeauFindlay/src/BeauFindlay.Api/Properties/serviceDependencies.json @@ -0,0 +1,11 @@ +{ + "dependencies": { + "appInsights1": { + "type": "appInsights" + }, + "storage1": { + "type": "storage", + "connectionId": "AzureWebJobsStorage" + } + } +} \ No newline at end of file diff --git a/BeauFindlay/src/BeauFindlay.Api/Properties/serviceDependencies.local.json b/BeauFindlay/src/BeauFindlay.Api/Properties/serviceDependencies.local.json new file mode 100644 index 0000000..b804a28 --- /dev/null +++ b/BeauFindlay/src/BeauFindlay.Api/Properties/serviceDependencies.local.json @@ -0,0 +1,11 @@ +{ + "dependencies": { + "appInsights1": { + "type": "appInsights.sdk" + }, + "storage1": { + "type": "storage.emulator", + "connectionId": "AzureWebJobsStorage" + } + } +} \ No newline at end of file diff --git a/BeauFindlay/src/BeauFindlay.Api/host.json b/BeauFindlay/src/BeauFindlay.Api/host.json new file mode 100644 index 0000000..ee5cf5f --- /dev/null +++ b/BeauFindlay/src/BeauFindlay.Api/host.json @@ -0,0 +1,12 @@ +{ + "version": "2.0", + "logging": { + "applicationInsights": { + "samplingSettings": { + "isEnabled": true, + "excludedTypes": "Request" + }, + "enableLiveMetricsFilters": true + } + } +} \ No newline at end of file diff --git a/BeauFindlay/src/BeauFindlay.Client/.idea/.idea.BeauFindlay.dir/.idea/.gitignore b/BeauFindlay/src/BeauFindlay.Client/.idea/.idea.BeauFindlay.dir/.idea/.gitignore new file mode 100644 index 0000000..ffddbd8 --- /dev/null +++ b/BeauFindlay/src/BeauFindlay.Client/.idea/.idea.BeauFindlay.dir/.idea/.gitignore @@ -0,0 +1,13 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Rider ignored files +/contentModel.xml +/modules.xml +/.idea.BeauFindlay.iml +/projectSettingsUpdater.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/BeauFindlay/src/BeauFindlay.Client/.idea/.idea.BeauFindlay.dir/.idea/.name b/BeauFindlay/src/BeauFindlay.Client/.idea/.idea.BeauFindlay.dir/.idea/.name new file mode 100644 index 0000000..0b27e6e --- /dev/null +++ b/BeauFindlay/src/BeauFindlay.Client/.idea/.idea.BeauFindlay.dir/.idea/.name @@ -0,0 +1 @@ +BeauFindlay \ No newline at end of file diff --git a/BeauFindlay/src/BeauFindlay.Client/.idea/.idea.BeauFindlay.dir/.idea/encodings.xml b/BeauFindlay/src/BeauFindlay.Client/.idea/.idea.BeauFindlay.dir/.idea/encodings.xml new file mode 100644 index 0000000..df87cf9 --- /dev/null +++ b/BeauFindlay/src/BeauFindlay.Client/.idea/.idea.BeauFindlay.dir/.idea/encodings.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/BeauFindlay/src/BeauFindlay.Client/.idea/.idea.BeauFindlay.dir/.idea/indexLayout.xml b/BeauFindlay/src/BeauFindlay.Client/.idea/.idea.BeauFindlay.dir/.idea/indexLayout.xml new file mode 100644 index 0000000..7b08163 --- /dev/null +++ b/BeauFindlay/src/BeauFindlay.Client/.idea/.idea.BeauFindlay.dir/.idea/indexLayout.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/BeauFindlay/src/BeauFindlay.Client/.idea/.idea.BeauFindlay.dir/.idea/vcs.xml b/BeauFindlay/src/BeauFindlay.Client/.idea/.idea.BeauFindlay.dir/.idea/vcs.xml new file mode 100644 index 0000000..c2365ab --- /dev/null +++ b/BeauFindlay/src/BeauFindlay.Client/.idea/.idea.BeauFindlay.dir/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/BeauFindlay/BeauFindlay/App.razor b/BeauFindlay/src/BeauFindlay.Client/App.razor similarity index 100% rename from BeauFindlay/BeauFindlay/App.razor rename to BeauFindlay/src/BeauFindlay.Client/App.razor diff --git a/BeauFindlay/BeauFindlay/BeauFindlay.csproj b/BeauFindlay/src/BeauFindlay.Client/BeauFindlay.Client.csproj similarity index 80% rename from BeauFindlay/BeauFindlay/BeauFindlay.csproj rename to BeauFindlay/src/BeauFindlay.Client/BeauFindlay.Client.csproj index e8c6c7a..4d4cdc2 100644 --- a/BeauFindlay/BeauFindlay/BeauFindlay.csproj +++ b/BeauFindlay/src/BeauFindlay.Client/BeauFindlay.Client.csproj @@ -11,4 +11,8 @@ + + + + diff --git a/BeauFindlay/src/BeauFindlay.Client/Components/Alert/Alert.razor b/BeauFindlay/src/BeauFindlay.Client/Components/Alert/Alert.razor new file mode 100644 index 0000000..bcc3a34 --- /dev/null +++ b/BeauFindlay/src/BeauFindlay.Client/Components/Alert/Alert.razor @@ -0,0 +1,46 @@ +
+
+
+ @if (Type == AlertType.Success) + { + + } + else + { + + } + +
+
+

+ @Title +

+
+

@ChildContent

+
+
+
+
+ +@code { + + [Parameter] + public string Title { get; set; } = string.Empty; + + [Parameter] + public RenderFragment? ChildContent { get; set; } + + [Parameter] + public AlertType Type { get; set; } + +} \ No newline at end of file diff --git a/BeauFindlay/src/BeauFindlay.Client/Components/Alert/AlertType.cs b/BeauFindlay/src/BeauFindlay.Client/Components/Alert/AlertType.cs new file mode 100644 index 0000000..133534f --- /dev/null +++ b/BeauFindlay/src/BeauFindlay.Client/Components/Alert/AlertType.cs @@ -0,0 +1,7 @@ +namespace BeauFindlay.Client.Components.Alert; + +public enum AlertType +{ + Success, + Error +} \ No newline at end of file diff --git a/BeauFindlay/src/BeauFindlay.Client/Components/Button/Button.razor b/BeauFindlay/src/BeauFindlay.Client/Components/Button/Button.razor new file mode 100644 index 0000000..f413027 --- /dev/null +++ b/BeauFindlay/src/BeauFindlay.Client/Components/Button/Button.razor @@ -0,0 +1,24 @@ + + +@code { + + [Parameter] + public RenderFragment? ChildContent { get; set; } + + [Parameter] + public string Type { get; set; } = "button"; + + [Parameter] + public bool IsLoading { get; set; } + +} \ No newline at end of file diff --git a/BeauFindlay/src/BeauFindlay.Client/Components/LoadingSpinner/LoadingSpinner.razor b/BeauFindlay/src/BeauFindlay.Client/Components/LoadingSpinner/LoadingSpinner.razor new file mode 100644 index 0000000..deeae40 --- /dev/null +++ b/BeauFindlay/src/BeauFindlay.Client/Components/LoadingSpinner/LoadingSpinner.razor @@ -0,0 +1,39 @@ +
+ + + Loading... +
+ +@code { + + [Parameter] + public LoadingSpinnerSize Size { get; set; } + + private string sizeCss = "w-8 h-8"; + + protected override void OnParametersSet() + { + SetSpinnerSize(); + } + + private void SetSpinnerSize() + { + sizeCss = Size switch + { + LoadingSpinnerSize.Small => "w-6 h-6", + LoadingSpinnerSize.Medium => "w-8 h-8", + LoadingSpinnerSize.Large => "w-12 h-12", + _ => sizeCss + }; + } + +} \ No newline at end of file diff --git a/BeauFindlay/src/BeauFindlay.Client/Components/LoadingSpinner/LoadingSpinnerSize.cs b/BeauFindlay/src/BeauFindlay.Client/Components/LoadingSpinner/LoadingSpinnerSize.cs new file mode 100644 index 0000000..360a53f --- /dev/null +++ b/BeauFindlay/src/BeauFindlay.Client/Components/LoadingSpinner/LoadingSpinnerSize.cs @@ -0,0 +1,8 @@ +namespace BeauFindlay.Client.Components.LoadingSpinner; + +public enum LoadingSpinnerSize +{ + Small, + Medium, + Large +} \ No newline at end of file diff --git a/BeauFindlay/BeauFindlay/Components/Typewriter/ITypewriterNotificationService.cs b/BeauFindlay/src/BeauFindlay.Client/Components/Typewriter/ITypewriterNotificationService.cs similarity index 76% rename from BeauFindlay/BeauFindlay/Components/Typewriter/ITypewriterNotificationService.cs rename to BeauFindlay/src/BeauFindlay.Client/Components/Typewriter/ITypewriterNotificationService.cs index 87ddda9..1984c37 100644 --- a/BeauFindlay/BeauFindlay/Components/Typewriter/ITypewriterNotificationService.cs +++ b/BeauFindlay/src/BeauFindlay.Client/Components/Typewriter/ITypewriterNotificationService.cs @@ -1,4 +1,4 @@ -namespace BeauFindlay.Components.Typewriter; +namespace BeauFindlay.Client.Components.Typewriter; public interface ITypewriterNotificationService { diff --git a/BeauFindlay/BeauFindlay/Components/Typewriter/Typewriter.razor b/BeauFindlay/src/BeauFindlay.Client/Components/Typewriter/Typewriter.razor similarity index 100% rename from BeauFindlay/BeauFindlay/Components/Typewriter/Typewriter.razor rename to BeauFindlay/src/BeauFindlay.Client/Components/Typewriter/Typewriter.razor diff --git a/BeauFindlay/BeauFindlay/Components/Typewriter/TypewriterConstants.cs b/BeauFindlay/src/BeauFindlay.Client/Components/Typewriter/TypewriterConstants.cs similarity index 73% rename from BeauFindlay/BeauFindlay/Components/Typewriter/TypewriterConstants.cs rename to BeauFindlay/src/BeauFindlay.Client/Components/Typewriter/TypewriterConstants.cs index 6153ff0..a44bdef 100644 --- a/BeauFindlay/BeauFindlay/Components/Typewriter/TypewriterConstants.cs +++ b/BeauFindlay/src/BeauFindlay.Client/Components/Typewriter/TypewriterConstants.cs @@ -1,4 +1,4 @@ -namespace BeauFindlay.Components.Typewriter; +namespace BeauFindlay.Client.Components.Typewriter; public static class TypewriterConstants { diff --git a/BeauFindlay/BeauFindlay/Components/Typewriter/TypewriterNotificationService.cs b/BeauFindlay/src/BeauFindlay.Client/Components/Typewriter/TypewriterNotificationService.cs similarity index 88% rename from BeauFindlay/BeauFindlay/Components/Typewriter/TypewriterNotificationService.cs rename to BeauFindlay/src/BeauFindlay.Client/Components/Typewriter/TypewriterNotificationService.cs index 95c6511..9cc05db 100644 --- a/BeauFindlay/BeauFindlay/Components/Typewriter/TypewriterNotificationService.cs +++ b/BeauFindlay/src/BeauFindlay.Client/Components/Typewriter/TypewriterNotificationService.cs @@ -1,4 +1,4 @@ -namespace BeauFindlay.Components.Typewriter; +namespace BeauFindlay.Client.Components.Typewriter; public class TypewriterNotificationService : ITypewriterNotificationService { diff --git a/BeauFindlay/BeauFindlay/Layout/Footer.razor b/BeauFindlay/src/BeauFindlay.Client/Layout/Footer.razor similarity index 95% rename from BeauFindlay/BeauFindlay/Layout/Footer.razor rename to BeauFindlay/src/BeauFindlay.Client/Layout/Footer.razor index 906da2b..da00afd 100644 --- a/BeauFindlay/BeauFindlay/Layout/Footer.razor +++ b/BeauFindlay/src/BeauFindlay.Client/Layout/Footer.razor @@ -1,5 +1,5 @@