Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions src/Orbit.Api/Controllers/ProfileController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
public record SetColorSchemeRequest(string? ColorScheme);
public record SetHandleRequest(string Handle);
public record SetSocialOptInRequest([property: JsonRequired] bool Enabled);
public record UpdateMarketingConsentRequest([property: JsonRequired] bool Enabled);
public record UpdatePublicProfileRequest(
[property: JsonRequired] bool Enabled,
bool ShowStreak,
Expand Down Expand Up @@ -98,7 +99,7 @@
var result = await mediator.Send(command, cancellationToken);

if (result.IsSuccess)
LogAiMemoryToggled(logger, request.Enabled ? "enabled" : "disabled", HttpContext.GetUserId());

Check warning on line 102 in src/Orbit.Api/Controllers/ProfileController.cs

View workflow job for this annotation

GitHub Actions / SonarCloud Analysis

Define a constant instead of using this literal 'enabled' 6 times.

return result.ToPayGateAwareResult(() => NoContent());
}
Expand Down Expand Up @@ -274,6 +275,23 @@
return result.ToPayGateAwareResult(() => NoContent());
}

[HttpPut("marketing-consent")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> UpdateMarketingConsent(
[FromBody] UpdateMarketingConsentRequest request,
CancellationToken cancellationToken)
{
var command = new UpdateMarketingConsentCommand(HttpContext.GetUserId(), request.Enabled);
var result = await mediator.Send(command, cancellationToken);

if (result.IsSuccess)
LogMarketingConsentChanged(logger, request.Enabled ? "enabled" : "disabled", HttpContext.GetUserId());

return result.ToPayGateAwareResult(() => NoContent());
}

[HttpPut("public")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
Expand Down Expand Up @@ -366,6 +384,9 @@
[LoggerMessage(EventId = 12, Level = LogLevel.Information, Message = "Public profile {State} for user {UserId}")]
private static partial void LogPublicProfileUpdated(ILogger logger, string state, Guid userId);

[LoggerMessage(EventId = 14, Level = LogLevel.Information, Message = "Marketing email consent {State} for user {UserId}")]
private static partial void LogMarketingConsentChanged(ILogger logger, string state, Guid userId);

[LoggerMessage(EventId = 13, Level = LogLevel.Information, Message = "Proactive Astra check-ins {State} for user {UserId}")]
private static partial void LogProactiveAstraToggled(ILogger logger, string state, Guid userId);

Expand Down
43 changes: 43 additions & 0 deletions src/Orbit.Api/Controllers/ResendWebhookController.cs
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
Expand Up @@ -52,6 +52,7 @@ private static void AddEmailAndSupabaseClients(WebApplicationBuilder builder, Ti
builder.Configuration.GetSection(WaitlistSettings.SectionName));
builder.Services.AddSingleton<IWaitlistConfirmationTokenService, WaitlistConfirmationTokenService>();
builder.Services.AddScoped<IMarketingContactsService, ResendContactsService>();
builder.Services.AddSingleton<IResendWebhookVerifier, ResendWebhookVerifier>();
}

private static void AddStripeBilling(WebApplicationBuilder builder)
Expand Down
5 changes: 4 additions & 1 deletion src/Orbit.Api/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,10 @@
"Resend": {
"ApiKey": "REPLACE-IN-DEVELOPMENT-JSON",
"FromEmail": "Orbit <noreply@send.useorbit.org>",
"SupportEmail": "REPLACE-IN-DEVELOPMENT-JSON"
"SupportEmail": "REPLACE-IN-DEVELOPMENT-JSON",
"ProductSegmentId": "",
"WaitlistSegmentId": "",
"WebhookSecret": ""
},
"Waitlist": {
"SigningKey": "",
Expand Down
2 changes: 2 additions & 0 deletions src/Orbit.Application/Common/ErrorCodes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ public static class ErrorCodes
public const string InvalidWebhookSignature = "INVALID_WEBHOOK_SIGNATURE";
public const string WebhookStripeApiError = "WEBHOOK_STRIPE_API_ERROR";
public const string WebhookProcessingFailed = "WEBHOOK_PROCESSING_FAILED";
public const string ResendWebhookSecretNotConfigured = "RESEND_WEBHOOK_SECRET_NOT_CONFIGURED";
public const string InvalidResendWebhookSignature = "INVALID_RESEND_WEBHOOK_SIGNATURE";
public const string PlayNotificationVerificationFailed = "PLAY_NOTIFICATION_VERIFICATION_FAILED";
public const string CodeRequestCooldown = "CODE_REQUEST_COOLDOWN";
public const string GoogleEmailUnavailable = "GOOGLE_EMAIL_UNAVAILABLE";
Expand Down
2 changes: 2 additions & 0 deletions src/Orbit.Application/Common/ErrorMessages.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ public static class ErrorMessages
public static readonly AppError InvalidWebhookSignature = new(ErrorCodes.InvalidWebhookSignature, "Invalid webhook signature");
public static readonly AppError WebhookStripeApiError = new(ErrorCodes.WebhookStripeApiError, "Stripe API error processing webhook event");
public static readonly AppError WebhookProcessingFailed = new(ErrorCodes.WebhookProcessingFailed, "Webhook processing failed");
public static readonly AppError ResendWebhookSecretNotConfigured = new(ErrorCodes.ResendWebhookSecretNotConfigured, "Resend webhook secret not configured");
public static readonly AppError InvalidResendWebhookSignature = new(ErrorCodes.InvalidResendWebhookSignature, "Invalid Resend webhook signature");
public static readonly AppError PlayNotificationVerificationFailed = new(ErrorCodes.PlayNotificationVerificationFailed, "Failed to verify Play notification");
public static readonly AppError CodeRequestCooldown = new(ErrorCodes.CodeRequestCooldown, "Please wait before requesting a new code");
public static readonly AppError VerificationCodeExpired = new(ErrorCodes.CodeExpired, "Verification code expired or not found");
Expand Down
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>;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[High] UpdateMarketingConsentCommand has no FluentValidation validator. Every sibling command in Profile/Validators/ has one — including the structurally identical SetSocialOptInCommand. Per the repo's cross-cutting hard rule, every new feature needs a validator in Orbit.Application/<Feature>/Validators/.

Fix: add Profile/Validators/UpdateMarketingConsentCommandValidator.cs:

public class UpdateMarketingConsentCommandValidator : AbstractValidator<UpdateMarketingConsentCommand>
{
    public UpdateMarketingConsentCommandValidator()
    {
        RuleFor(x => x.UserId).NotEmpty();
    }
}


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);
}
6 changes: 4 additions & 2 deletions src/Orbit.Application/Profile/Queries/GetProfileQuery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ public record ProfileResponse(
bool SocialOptIn,
bool Uses24HourClock = true,
PublicProfileSettings? PublicProfile = null,
bool ProactiveAstraEnabled = false);
bool ProactiveAstraEnabled = false,
bool? MarketingEmailConsent = null);

public record GetProfileQuery(Guid UserId) : IRequest<Result<ProfileResponse>>;

Expand Down Expand Up @@ -141,6 +142,7 @@ public async Task<Result<ProfileResponse>> Handle(GetProfileQuery request, Cance
user.SocialOptIn,
TimeFormatResolver.Uses24HourClock(user.TimeZone),
publicProfile,
user.ProactiveAstraEnabled));
user.ProactiveAstraEnabled,
user.MarketingEmailConsent));
}
}
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

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using 'string.Equals(string, StringComparison)' to perform a case-insensitive comparison, but keep in mind that this might cause subtle changes in behavior, so make sure to conduct thorough testing after applying the suggestion, or if culturally sensitive comparison is not required, consider using 'StringComparison.OrdinalIgnoreCase'

See more on https://sonarcloud.io/project/issues?id=thomasluizon_orbit-api&issues=AZ8wuFNU2ZtEwoSjSDo5&open=AZ8wuFNU2ZtEwoSjSDo5&pullRequest=287
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);
}
13 changes: 13 additions & 0 deletions src/Orbit.Domain/Entities/User.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ public partial class User : Entity
public Guid? ReferredByUserId { get; private set; }
public string? Handle { get; private set; }
public bool SocialOptIn { get; private set; }
public bool? MarketingEmailConsent { get; private set; }
public DateTime? MarketingConsentUpdatedAtUtc { get; private set; }
public int TotalXp { get; private set; } = 0;
public int Level { get; private set; } = 1;
public string? ReferralCouponId { get; private set; }
Expand Down Expand Up @@ -397,6 +399,17 @@ public Result SetHandle(string handle)

public void SetSocialOptIn(bool enabled) => SocialOptIn = enabled;

/// <summary>
/// Records the user's explicit product-marketing-email consent decision (<c>true</c> = opted in,
/// <c>false</c> = opted out) and stamps the decision time. A null value means never asked; once set
/// it stays non-null so the one-time in-app consent prompt never shows again.
/// </summary>
public void SetMarketingConsent(bool consented)
{
MarketingEmailConsent = consented;
MarketingConsentUpdatedAtUtc = DateTime.UtcNow;
}

/// <summary>
/// Sets (or clears, when null) the opaque token that addresses the user's public shareable
/// profile at <c>/u/{slug}</c>. A null slug means the public profile is disabled.
Expand Down
22 changes: 19 additions & 3 deletions src/Orbit.Domain/Interfaces/IMarketingContactsService.cs
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);
}
18 changes: 18 additions & 0 deletions src/Orbit.Domain/Interfaces/IResendWebhookVerifier.cs
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);
}
3 changes: 3 additions & 0 deletions src/Orbit.Infrastructure/Configuration/ResendSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,7 @@ public sealed class ResendSettings
public required string ApiKey { get; init; }
public required string FromEmail { get; init; }
public string SupportEmail { get; init; } = "contact@useorbit.org";
public string ProductSegmentId { get; init; } = "";
public string WaitlistSegmentId { get; init; } = "";
public string WebhookSecret { get; init; } = "";
}
Loading
Loading