Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
46 changes: 46 additions & 0 deletions src/Orbit.Api/Controllers/AdminController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Orbit.Api.Extensions;
using Orbit.Api.RateLimiting;
using Orbit.Application.Common;
using Orbit.Application.Marketing.Commands;

namespace Orbit.Api.Controllers;

[ApiController]
[Route("api/admin")]
[Authorize(Policy = AdminPolicy.Name)]
public partial class AdminController(IMediator mediator, ILogger<AdminController> logger) : ControllerBase
{
public record BroadcastRequest(
string SubjectEn,
string SubjectPt,
string BodyHtmlEn,
string BodyHtmlPt,
string? TestEmail);

[HttpPost("marketing/broadcast")]
[DistributedRateLimit("admin-broadcast")]
[ProducesResponseType(StatusCodes.Status202Accepted)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> SendMarketingBroadcast(
[FromBody] BroadcastRequest request,
CancellationToken cancellationToken)
{
var command = new SendMarketingBroadcastCommand(
request.SubjectEn, request.SubjectPt, request.BodyHtmlEn, request.BodyHtmlPt, request.TestEmail);

var result = await mediator.Send(command, cancellationToken);
if (result.IsFailure)
return result.ToErrorResult();

LogBroadcastRequested(logger, HttpContext.GetUserId(), result.Value.RecipientCount, result.Value.WasTest);

Check warning on line 40 in src/Orbit.Api/Controllers/AdminController.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Evaluation of this argument may be expensive and unnecessary if logging is disabled

See more on https://sonarcloud.io/project/issues?id=thomasluizon_orbit-api&issues=AZ8zptOnPgOMskWIuWkF&open=AZ8zptOnPgOMskWIuWkF&pullRequest=287
return Accepted(new { recipientCount = result.Value.RecipientCount, test = result.Value.WasTest });
}

[LoggerMessage(EventId = 1, Level = LogLevel.Information, Message = "Marketing broadcast requested by admin {UserId}: {RecipientCount} recipients, test={IsTest}")]
private static partial void LogBroadcastRequested(ILogger logger, Guid userId, int recipientCount, bool isTest);
}
77 changes: 77 additions & 0 deletions src/Orbit.Api/Controllers/MarketingController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Orbit.Api.RateLimiting;
using Orbit.Application.Common;
using Orbit.Application.Marketing.Commands;

namespace Orbit.Api.Controllers;

[ApiController]
[Route("api/marketing")]
[AllowAnonymous]
public class MarketingController(IMediator mediator) : ControllerBase
{
[HttpGet("unsubscribe")]
[DistributedRateLimit("marketing-unsubscribe")]
[ApiExplorerSettings(IgnoreApi = true)]
public async Task<IActionResult> Unsubscribe(
[FromQuery] string token,
[FromQuery] string? lang,
CancellationToken cancellationToken)
{
var result = await mediator.Send(new UnsubscribeMarketingCommand(token ?? ""), cancellationToken);
var isPtBr = LocaleHelper.IsPortuguese(lang);

return result.IsSuccess
? Content(ConfirmationPageHtml(isPtBr), "text/html")
: new ContentResult
{
Content = InvalidPageHtml(isPtBr),
ContentType = "text/html",
StatusCode = StatusCodes.Status400BadRequest,
};
}

[HttpPost("unsubscribe")]
[DistributedRateLimit("marketing-unsubscribe")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> UnsubscribeOneClick(
[FromQuery] string token,
CancellationToken cancellationToken)
{
var result = await mediator.Send(new UnsubscribeMarketingCommand(token ?? ""), cancellationToken);
return result.IsSuccess ? Ok() : BadRequest();
}

private static string ConfirmationPageHtml(bool isPtBr)
{
var (title, message) = isPtBr
? ("Inscrição cancelada", "Você não receberá mais e-mails de novidades do Orbit.")
: ("You're unsubscribed", "You will no longer receive product-update emails from Orbit.");
return Page(title, message);
}

private static string InvalidPageHtml(bool isPtBr)
{
var (title, message) = isPtBr
? ("Link inválido", "Este link para cancelar inscrição é inválido ou expirou.")
: ("Invalid link", "This unsubscribe link is invalid or has expired.");
return Page(title, message);
}

private static string Page(string title, string message) =>
$"""
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>{title}</title></head>
<body style="margin:0;background-color:#020618;font-family:-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
<div style="max-width:480px;margin:0 auto;padding:80px 24px;text-align:center;color:#F8FAFC;">
<h1 style="font-size:22px;margin:0 0 12px;">{title}</h1>
<p style="font-size:15px;line-height:1.5;color:#90A1B9;margin:0;">{message}</p>
</div>
</body>
</html>
""";
}
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 SetThemePreferenceRequest(string? ThemePreference);
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 @@ -311,6 +312,23 @@ public async Task<IActionResult> SetSocialOptIn(
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 @@ -403,6 +421,9 @@ public async Task<IActionResult> ExportUserData(CancellationToken cancellationTo
[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
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,10 @@ private static void AddEmailAndSupabaseClients(WebApplicationBuilder builder, Ti

builder.Services.Configure<WaitlistSettings>(
builder.Configuration.GetSection(WaitlistSettings.SectionName));
builder.Services.Configure<MarketingSettings>(
builder.Configuration.GetSection(MarketingSettings.SectionName));
builder.Services.AddSingleton<IWaitlistConfirmationTokenService, WaitlistConfirmationTokenService>();
builder.Services.AddSingleton<IMarketingUnsubscribeTokenService, MarketingUnsubscribeTokenService>();
builder.Services.AddScoped<IMarketingContactsService, ResendContactsService>();
}

Expand Down
6 changes: 5 additions & 1 deletion src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,11 @@
};
});

builder.Services.AddAuthorization();
builder.Services.AddAuthorization(options =>
{
options.AddPolicy(AdminPolicy.Name, policy =>
policy.RequireClaim(AdminPolicy.ClaimType, AdminPolicy.ClaimValue));
});

Check warning on line 161 in src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use AddAuthorizationBuilder to register authorization services and construct policies

See more on https://sonarcloud.io/project/issues?id=thomasluizon_orbit-api&issues=AZ8zptTSPgOMskWIuWkG&open=AZ8zptTSPgOMskWIuWkG&pullRequest=287

return builder;
}
Expand Down
9 changes: 8 additions & 1 deletion src/Orbit.Api/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,9 @@
"Resend": {
"ApiKey": "REPLACE-IN-DEVELOPMENT-JSON",
"FromEmail": "Orbit <noreply@send.useorbit.org>",
"SupportEmail": "REPLACE-IN-DEVELOPMENT-JSON"
"SupportEmail": "REPLACE-IN-DEVELOPMENT-JSON",
"MarketingFromEmail": "Orbit <news@updates.useorbit.org>",
"MarketingRetryBaseDelayMs": 500
},
"Waitlist": {
"SigningKey": "",
Expand All @@ -74,6 +76,11 @@
"Frontend": {
"BaseUrl": "https://app.useorbit.org"
},
"Marketing": {
"ApiBaseUrl": "https://api.useorbit.org",
"SendDelayMilliseconds": 100,
"UnsubscribeSigningKey": ""
},
"Cors": {
"AllowedOrigins": [
"http://localhost:3000",
Expand Down
2 changes: 1 addition & 1 deletion src/Orbit.Application/Auth/Commands/GoogleAuthCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
if (wasReactivated || request.GoogleAccessToken is not null)
await unitOfWork.SaveChangesAsync(cancellationToken);

var sessionResult = await authSessionService.CreateSessionAsync(user.Id, user.Email, cancellationToken);
var sessionResult = await authSessionService.CreateSessionAsync(user.Id, user.Email, user.IsAdmin, cancellationToken);
if (sessionResult.IsFailure)
return sessionResult.PropagateError<LoginResponse>();

Expand Down Expand Up @@ -152,7 +152,7 @@
}

private bool HandlePostLogin(
User user, GoogleAuthCommand request, bool isNewUser, CancellationToken cancellationToken)

Check warning on line 155 in src/Orbit.Application/Auth/Commands/GoogleAuthCommand.cs

View workflow job for this annotation

GitHub Actions / SonarCloud Analysis

Remove this unused method parameter 'cancellationToken'.
{
if (isNewUser && !string.IsNullOrWhiteSpace(request.ReferralCode))
ProcessReferralInBackground(user.Id, request.ReferralCode);
Expand Down
2 changes: 1 addition & 1 deletion src/Orbit.Application/Auth/Commands/VerifyCodeCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ public async Task<Result<LoginResponse>> Handle(VerifyCodeCommand request, Cance

var wasReactivated = await HandlePostLoginAsync(user, isNewUser, request, cancellationToken);

var sessionResult = await authSessionService.CreateSessionAsync(user.Id, user.Email, cancellationToken);
var sessionResult = await authSessionService.CreateSessionAsync(user.Id, user.Email, user.IsAdmin, cancellationToken);
if (sessionResult.IsFailure)
return sessionResult.PropagateError<LoginResponse>();

Expand Down
14 changes: 14 additions & 0 deletions src/Orbit.Application/Common/AdminPolicy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace Orbit.Application.Common;

/// <summary>
/// Names the single administrative authorization boundary. A user's IsAdmin flag mints the
/// admin claim into the JWT; the "Admin" policy (RequireClaim on that claim) gates admin-only
/// endpoints. The first admin is granted by a direct DB update (Users.IsAdmin = true); there is
/// no email-based gate.
/// </summary>
public static class AdminPolicy
{
public const string Name = "Admin";
public const string ClaimType = "admin";
public const string ClaimValue = "true";
}
1 change: 1 addition & 0 deletions src/Orbit.Application/Common/ErrorCodes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ 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 InvalidUnsubscribeToken = "INVALID_UNSUBSCRIBE_TOKEN";
public const string PlayNotificationVerificationFailed = "PLAY_NOTIFICATION_VERIFICATION_FAILED";
public const string CodeRequestCooldown = "CODE_REQUEST_COOLDOWN";
public const string GoogleEmailUnavailable = "GOOGLE_EMAIL_UNAVAILABLE";
Expand Down
1 change: 1 addition & 0 deletions src/Orbit.Application/Common/ErrorMessages.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ 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 InvalidUnsubscribeToken = new(ErrorCodes.InvalidUnsubscribeToken, "Invalid or expired unsubscribe link");
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
10 changes: 10 additions & 0 deletions src/Orbit.Application/Common/MarketingSettings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace Orbit.Application.Common;

public class MarketingSettings
{
public const string SectionName = "Marketing";

public string ApiBaseUrl { get; set; } = "https://api.useorbit.org";
public int SendDelayMilliseconds { get; set; } = 100;
public string UnsubscribeSigningKey { get; set; } = "";
}
Loading
Loading