Repository navigation
feat(api): marketing email consent + code-based broadcast sender + admin concept #287
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
9434142
38f94a7
8d11291
a42fb11
41314e0
deb2a01
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| using MediatR; | ||
| using Microsoft.AspNetCore.Authorization; | ||
| using Microsoft.AspNetCore.Mvc; | ||
| using Orbit.Api.Extensions; | ||
| using Orbit.Application.Common; | ||
| using Orbit.Application.Resend.Commands; | ||
|
|
||
| namespace Orbit.Api.Controllers; | ||
|
|
||
| [AllowAnonymous] | ||
| [ApiController] | ||
| [Route("api/webhooks/resend")] | ||
| public partial class ResendWebhookController(IMediator mediator, ILogger<ResendWebhookController> logger) : ControllerBase | ||
| { | ||
| #pragma warning disable S6932 // Raw Request.Body and Request.Headers needed for Svix webhook signature verification | ||
| [HttpPost] | ||
| [ProducesResponseType(StatusCodes.Status200OK)] | ||
| [ProducesResponseType(StatusCodes.Status400BadRequest)] | ||
| [ProducesResponseType(StatusCodes.Status500InternalServerError)] | ||
| public async Task<IActionResult> HandleWebhook(CancellationToken cancellationToken) | ||
| { | ||
| var payload = await new StreamReader(HttpContext.Request.Body).ReadToEndAsync(cancellationToken); | ||
| var command = new HandleResendWebhookCommand( | ||
| payload, | ||
| Request.Headers["svix-id"].ToString(), | ||
| Request.Headers["svix-timestamp"].ToString(), | ||
| Request.Headers["svix-signature"].ToString()); | ||
|
|
||
| var result = await mediator.Send(command, cancellationToken); | ||
| if (result.IsSuccess) | ||
| return Ok(); | ||
|
|
||
| LogWebhookRejected(logger, result.Error); | ||
| var failureStatus = result.ErrorCode == ErrorCodes.InvalidResendWebhookSignature | ||
| ? StatusCodes.Status400BadRequest | ||
| : StatusCodes.Status500InternalServerError; | ||
| return result.ToErrorResult(failureStatus); | ||
| } | ||
| #pragma warning restore S6932 | ||
|
|
||
| [LoggerMessage(EventId = 1, Level = LogLevel.Error, Message = "Resend webhook rejected: {Error}")] | ||
| private static partial void LogWebhookRejected(ILogger logger, string? error); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| using MediatR; | ||
| using Microsoft.Extensions.Logging; | ||
| using Orbit.Application.Common; | ||
| using Orbit.Domain.Common; | ||
| using Orbit.Domain.Entities; | ||
| using Orbit.Domain.Interfaces; | ||
|
|
||
| namespace Orbit.Application.Profile.Commands; | ||
|
|
||
| public record UpdateMarketingConsentCommand(Guid UserId, bool Enabled) : IRequest<Result>; | ||
|
|
||
| public partial class UpdateMarketingConsentCommandHandler( | ||
| IGenericRepository<User> userRepository, | ||
| IUnitOfWork unitOfWork, | ||
| IMarketingContactsService contactsService, | ||
| ILogger<UpdateMarketingConsentCommandHandler> logger) : IRequestHandler<UpdateMarketingConsentCommand, Result> | ||
| { | ||
| public async Task<Result> Handle(UpdateMarketingConsentCommand request, CancellationToken cancellationToken) | ||
| { | ||
| var user = await userRepository.FindOneTrackedAsync( | ||
| u => u.Id == request.UserId, | ||
| cancellationToken: cancellationToken); | ||
|
|
||
| if (user is null) | ||
| return Result.Failure(ErrorMessages.UserNotFound); | ||
|
|
||
| user.SetMarketingConsent(request.Enabled); | ||
| await unitOfWork.SaveChangesAsync(cancellationToken); | ||
|
|
||
| await SyncResendContactAsync(user, request.Enabled, cancellationToken); | ||
|
|
||
| return Result.Success(); | ||
| } | ||
|
|
||
| private async Task SyncResendContactAsync(User user, bool enabled, CancellationToken cancellationToken) | ||
| { | ||
| try | ||
| { | ||
| if (enabled) | ||
| await contactsService.UpsertProductContactAsync( | ||
| user.Email, user.Language, user.HasProAccess ? "pro" : "free", cancellationToken); | ||
| else | ||
| await contactsService.SetContactUnsubscribedAsync(user.Email, unsubscribed: true, cancellationToken); | ||
| } | ||
| catch (Exception ex) when (ex is not OperationCanceledException) | ||
| { | ||
| LogResendSyncFailed(logger, ex, user.Id); | ||
| } | ||
| } | ||
|
|
||
| [LoggerMessage(EventId = 1, Level = LogLevel.Error, Message = "Marketing consent persisted but Resend sync failed for user {UserId}")] | ||
| private static partial void LogResendSyncFailed(ILogger logger, Exception ex, Guid userId); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,93 @@ | ||
| using System.Text.Json; | ||
| using MediatR; | ||
| using Microsoft.Extensions.Logging; | ||
| using Orbit.Application.Common; | ||
| using Orbit.Domain.Common; | ||
| using Orbit.Domain.Entities; | ||
| using Orbit.Domain.Interfaces; | ||
|
|
||
| namespace Orbit.Application.Resend.Commands; | ||
|
|
||
| public record HandleResendWebhookCommand(string Payload, string SvixId, string SvixTimestamp, string SvixSignature) | ||
| : IRequest<Result>; | ||
|
|
||
| public partial class HandleResendWebhookCommandHandler( | ||
| IResendWebhookVerifier verifier, | ||
| IGenericRepository<User> userRepository, | ||
| IUnitOfWork unitOfWork, | ||
| ILogger<HandleResendWebhookCommandHandler> logger) : IRequestHandler<HandleResendWebhookCommand, Result> | ||
| { | ||
| public async Task<Result> Handle(HandleResendWebhookCommand request, CancellationToken cancellationToken) | ||
| { | ||
| switch (verifier.Verify(request.Payload, request.SvixId, request.SvixTimestamp, request.SvixSignature)) | ||
| { | ||
| case ResendWebhookVerification.SecretNotConfigured: | ||
| LogSecretNotConfigured(logger); | ||
| return Result.Failure(ErrorMessages.ResendWebhookSecretNotConfigured); | ||
| case ResendWebhookVerification.InvalidSignature: | ||
| LogInvalidSignature(logger); | ||
| return Result.Failure(ErrorMessages.InvalidResendWebhookSignature); | ||
| } | ||
|
|
||
| if (!TryParseUnsubscribedContact(request.Payload, out var email)) | ||
| return Result.Success(); | ||
|
|
||
| var normalizedEmail = email.ToLowerInvariant(); | ||
| var user = await userRepository.FindOneTrackedAsync( | ||
| u => u.Email.ToLower() == normalizedEmail, | ||
|
Check warning on line 37 in src/Orbit.Application/Resend/Commands/HandleResendWebhookCommand.cs
|
||
| cancellationToken: cancellationToken); | ||
|
|
||
| if (user is null) | ||
| { | ||
| LogUnknownContact(logger); | ||
| return Result.Success(); | ||
| } | ||
|
|
||
| user.SetMarketingConsent(false); | ||
| await unitOfWork.SaveChangesAsync(cancellationToken); | ||
| LogConsentRevoked(logger, user.Id); | ||
| return Result.Success(); | ||
| } | ||
|
|
||
| private static bool TryParseUnsubscribedContact(string payload, out string email) | ||
| { | ||
| email = ""; | ||
| try | ||
| { | ||
| using var document = JsonDocument.Parse(payload); | ||
| var root = document.RootElement; | ||
|
|
||
| if (!root.TryGetProperty("type", out var type) || type.GetString() != "contact.updated") | ||
| return false; | ||
| if (!root.TryGetProperty("data", out var data)) | ||
| return false; | ||
| if (!data.TryGetProperty("unsubscribed", out var unsubscribed) || unsubscribed.ValueKind != JsonValueKind.True) | ||
| return false; | ||
| if (!data.TryGetProperty("email", out var emailElement) || emailElement.ValueKind != JsonValueKind.String) | ||
| return false; | ||
|
|
||
| var value = emailElement.GetString(); | ||
| if (string.IsNullOrWhiteSpace(value)) | ||
| return false; | ||
|
|
||
| email = value; | ||
| return true; | ||
| } | ||
| catch (JsonException) | ||
| { | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| [LoggerMessage(EventId = 1, Level = LogLevel.Critical, Message = "Resend WebhookSecret is not configured -- rejecting webhook")] | ||
| private static partial void LogSecretNotConfigured(ILogger logger); | ||
|
|
||
| [LoggerMessage(EventId = 2, Level = LogLevel.Warning, Message = "Resend webhook signature verification failed")] | ||
| private static partial void LogInvalidSignature(ILogger logger); | ||
|
|
||
| [LoggerMessage(EventId = 3, Level = LogLevel.Information, Message = "Resend contact.updated for an email with no matching user -- ignoring")] | ||
| private static partial void LogUnknownContact(ILogger logger); | ||
|
|
||
| [LoggerMessage(EventId = 4, Level = LogLevel.Information, Message = "Marketing consent revoked for user {UserId} via Resend unsubscribe webhook")] | ||
| private static partial void LogConsentRevoked(ILogger logger, Guid userId); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,11 +1,27 @@ | ||
| namespace Orbit.Domain.Interfaces; | ||
|
|
||
| /// <summary> | ||
| /// Adds a confirmed subscriber to the marketing contacts list used for the | ||
| /// iOS-launch waitlist. Distinct from <see cref="IEmailService"/>, which sends | ||
| /// transactional mail; this manages the subscriber list itself. | ||
| /// Manages the Resend marketing-contact list and its segment memberships. Distinct from | ||
| /// <see cref="IEmailService"/>, which sends transactional mail; this owns the subscriber list | ||
| /// itself. Every method is fire-and-forget: it treats an already-existing/absent contact as | ||
| /// success and never throws, so a Resend outage can never roll back the local decision that | ||
| /// triggered the sync. | ||
| /// </summary> | ||
| public interface IMarketingContactsService | ||
| { | ||
| /// <summary>Adds a confirmed waitlist subscriber and places them in the waitlist segment.</summary> | ||
| Task AddContactAsync(string email, CancellationToken cancellationToken = default); | ||
|
|
||
| /// <summary> | ||
| /// Opt-in path: creates or updates the global contact with <c>unsubscribed:false</c> and the | ||
| /// user's <paramref name="locale"/>/<paramref name="plan"/> custom properties, then ensures it | ||
| /// belongs to the product segment so dashboard broadcasts can target it. | ||
| /// </summary> | ||
| Task UpsertProductContactAsync(string email, string? locale, string plan, CancellationToken cancellationToken = default); | ||
|
|
||
| /// <summary>Opt-out path: sets the contact's global <c>unsubscribed</c> flag.</summary> | ||
| Task SetContactUnsubscribedAsync(string email, bool unsubscribed, CancellationToken cancellationToken = default); | ||
|
|
||
| /// <summary>Account-deletion path: deletes the global contact (LGPD erasure).</summary> | ||
| Task RemoveContactAsync(string email, CancellationToken cancellationToken = default); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| namespace Orbit.Domain.Interfaces; | ||
|
|
||
| public enum ResendWebhookVerification | ||
| { | ||
| Verified, | ||
| SecretNotConfigured, | ||
| InvalidSignature | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Verifies the Svix signature Resend attaches to every webhook delivery. Implemented in | ||
| /// Infrastructure (which owns the Svix dependency and the signing secret) so the Application | ||
| /// webhook handler stays free of both. | ||
| /// </summary> | ||
| public interface IResendWebhookVerifier | ||
| { | ||
| ResendWebhookVerification Verify(string payload, string svixId, string svixTimestamp, string svixSignature); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[High]
UpdateMarketingConsentCommandhas no FluentValidation validator. Every sibling command inProfile/Validators/has one — including the structurally identicalSetSocialOptInCommand. Per the repo's cross-cutting hard rule, every new feature needs a validator inOrbit.Application/<Feature>/Validators/.Fix: add
Profile/Validators/UpdateMarketingConsentCommandValidator.cs: