From 8e2624ee725c6dd03b72239b6595f5ef8b70e681 Mon Sep 17 00:00:00 2001 From: Jerry Phillips Date: Wed, 20 May 2026 19:56:26 -0400 Subject: [PATCH] feat(#251): add waitlist notification builder and signup endpoint - Add EmailTemplate.WaitlistConfirmation = 8 - Add BrevoListIds.Waitlist = 5 to BrevoService - Add INotificationMessageBuilder.BuildWaitlistSignup + implementation (confirmation email: founding-member $19/mo rate reserved) - Add INotificationService.SendWaitlistSignupNotificationAsync + implementation - Add POST /api/email/waitlist-signup endpoint: Turnstile verify (waitlist-signup), adds contact to Brevo list 5 (hardcoded, not client-supplied), fires confirmation email fire-and-forget with CancellationToken.None - Add SendWaitlistSignupNotificationAsync stub to NoOpNotificationService in tests ADO: #251, #254 --- JobFlow.API/Controllers/EmailController.cs | 37 +++++++++++++++++++ .../Builders/INotificationMessageBuilder.cs | 2 + .../Builders/NotificationMessageBuilder.cs | 24 ++++++++++++ .../Notifications/Enums/EmailTemplate.cs | 3 +- .../Notifications/NotificationService.cs | 6 +++ .../ServiceInterfaces/INotificationService.cs | 3 ++ .../ExternalServices/Brevo/BrevoService.cs | 1 + .../FollowUpAutomationServiceTests.cs | 1 + 8 files changed, 76 insertions(+), 1 deletion(-) diff --git a/JobFlow.API/Controllers/EmailController.cs b/JobFlow.API/Controllers/EmailController.cs index b0f6742..a0f352c 100644 --- a/JobFlow.API/Controllers/EmailController.cs +++ b/JobFlow.API/Controllers/EmailController.cs @@ -76,4 +76,41 @@ public async Task SendContactForm( ? Ok(new { message = "Contact form submitted." }) : StatusCode(500, new { message = "Failed to submit." }); } + + [HttpPost] + [Route("waitlist-signup")] + public async Task WaitlistSignup( + [FromBody] NewsletterSubscriptionRequest request, + [FromServices] IBrevoService brevoService, + [FromServices] INotificationService notificationService, + [FromServices] ICaptchaVerificationService captchaService, + CancellationToken cancellationToken) + { + var remoteIp = HttpContext.Connection.RemoteIpAddress?.ToString(); + + var verification = await captchaService.VerifyAsync( + request.CaptchaToken, + "waitlist-signup", + remoteIp, + cancellationToken); + + if (!verification.IsValid) + { + return BadRequest(new + { + message = "Turnstile validation failed.", + errors = verification.ErrorCodes + }); + } + + var added = await brevoService.AddContactAsync(request.Email, 5 /* BrevoListIds.Waitlist */); + if (!added) + return StatusCode(500, new { message = "Failed to join waitlist." }); + + // Fire-and-forget confirmation email — use CancellationToken.None so the task + // is not cancelled when the HTTP response completes. + _ = Task.Run(() => notificationService.SendWaitlistSignupNotificationAsync(request.Email), CancellationToken.None); + + return Ok(new { message = "Added to waitlist." }); + } } \ No newline at end of file diff --git a/JobFlow.Business/Notifications/Builders/INotificationMessageBuilder.cs b/JobFlow.Business/Notifications/Builders/INotificationMessageBuilder.cs index fcde948..db38d38 100644 --- a/JobFlow.Business/Notifications/Builders/INotificationMessageBuilder.cs +++ b/JobFlow.Business/Notifications/Builders/INotificationMessageBuilder.cs @@ -40,4 +40,6 @@ NotificationMessage BuildClientJobRescheduled( NotificationMessage BuildOrganizationClientJobUpdate(Organization organization, OrganizationClient client, Job job, string updateMessage); NotificationMessage BuildOrganizationClientPortalMagicLink(OrganizationClient client, string magicLink); + + NotificationMessage BuildWaitlistSignup(string email); } \ No newline at end of file diff --git a/JobFlow.Business/Notifications/Builders/NotificationMessageBuilder.cs b/JobFlow.Business/Notifications/Builders/NotificationMessageBuilder.cs index 9496aee..d49414f 100644 --- a/JobFlow.Business/Notifications/Builders/NotificationMessageBuilder.cs +++ b/JobFlow.Business/Notifications/Builders/NotificationMessageBuilder.cs @@ -457,4 +457,28 @@ private static string FormatScheduleRange(DateTimeOffset start, DateTimeOffset? return $"{localStart:MMM dd, yyyy h:mm tt} - {localEnd:MMM dd, yyyy h:mm tt}"; } + + public NotificationMessage BuildWaitlistSignup(string email) + { + return new NotificationMessage + { + Name = "Founding Member", + Email = email, + Subject = "You're on the JobFlow waitlist — your rate is reserved", + Body = """ + Hi there, + + You're officially on the JobFlow early-access waitlist. + + As a founding member, your $19/mo rate on the Go plan is reserved for you — + locked in for life, even when pricing increases at general availability. + + We'll reach out as soon as your spot is ready. In the meantime, feel free to + reply to this email with any questions. + + — The JobFlow Team + """, + TemplateId = EmailTemplate.WaitlistConfirmation + }; + } } \ No newline at end of file diff --git a/JobFlow.Business/Notifications/Enums/EmailTemplate.cs b/JobFlow.Business/Notifications/Enums/EmailTemplate.cs index 76db692..7661181 100644 --- a/JobFlow.Business/Notifications/Enums/EmailTemplate.cs +++ b/JobFlow.Business/Notifications/Enums/EmailTemplate.cs @@ -8,5 +8,6 @@ public enum EmailTemplate InvoiceReminder = 6, OnTheWayNotification = 4, ArrivalNotification = 5, - EmployeeInvite = 7 + EmployeeInvite = 7, + WaitlistConfirmation = 8 } \ No newline at end of file diff --git a/JobFlow.Business/Notifications/NotificationService.cs b/JobFlow.Business/Notifications/NotificationService.cs index 1951dfa..55c06dc 100644 --- a/JobFlow.Business/Notifications/NotificationService.cs +++ b/JobFlow.Business/Notifications/NotificationService.cs @@ -150,6 +150,12 @@ public async Task SendOrganizationClientPortalMagicLinkAsync(OrganizationClient await SendNotificationAsync(message); } + public async Task SendWaitlistSignupNotificationAsync(string email) + { + var message = _builder.BuildWaitlistSignup(email); + await SendNotificationAsync(message); + } + public async Task SendOrganizationSubscriptionPaymentFailedNotificationAsync(Organization org) { var message = _builder.BuildOrganizationSubscriptionFailed(org); diff --git a/JobFlow.Business/Services/ServiceInterfaces/INotificationService.cs b/JobFlow.Business/Services/ServiceInterfaces/INotificationService.cs index 998f174..1b84566 100644 --- a/JobFlow.Business/Services/ServiceInterfaces/INotificationService.cs +++ b/JobFlow.Business/Services/ServiceInterfaces/INotificationService.cs @@ -39,4 +39,7 @@ Task SendClientJobRescheduledNotificationAsync( // Employee notifications Task SendEmployeeInviteNotificationAsync(EmployeeInvite invite); + + // Public / marketing notifications + Task SendWaitlistSignupNotificationAsync(string email); } \ No newline at end of file diff --git a/JobFlow.Infrastructure/ExternalServices/Brevo/BrevoService.cs b/JobFlow.Infrastructure/ExternalServices/Brevo/BrevoService.cs index 6e01832..82f5559 100644 --- a/JobFlow.Infrastructure/ExternalServices/Brevo/BrevoService.cs +++ b/JobFlow.Infrastructure/ExternalServices/Brevo/BrevoService.cs @@ -16,6 +16,7 @@ internal static class BrevoListIds { public const int Newsletter = 3; public const int TrialUsers = 4; + public const int Waitlist = 5; } [SingletonService] diff --git a/JobFlow.Tests/FollowUpAutomationServiceTests.cs b/JobFlow.Tests/FollowUpAutomationServiceTests.cs index e507996..b2ddd33 100644 --- a/JobFlow.Tests/FollowUpAutomationServiceTests.cs +++ b/JobFlow.Tests/FollowUpAutomationServiceTests.cs @@ -283,5 +283,6 @@ private sealed class NoOpNotificationService : INotificationService public Task SendOrganizationClientJobUpdateNotificationAsync(Organization organization, OrganizationClient client, Job job, string updateMessage) => Task.CompletedTask; public Task SendOrganizationClientPortalMagicLinkAsync(OrganizationClient client, string magicLink) => Task.CompletedTask; public Task SendEmployeeInviteNotificationAsync(EmployeeInvite invite) => Task.CompletedTask; + public Task SendWaitlistSignupNotificationAsync(string email) => Task.CompletedTask; } }