feat: add release digest controls
All checks were successful
deploy-socialize / image (push) Successful in 1m13s
deploy-socialize / deploy (push) Successful in 19s

This commit is contained in:
2026-05-08 08:30:47 -04:00
parent 0b7edb1b7f
commit c527011646
23 changed files with 3085 additions and 25 deletions

View File

@@ -37,7 +37,8 @@ internal class IdentityService(
Firstname = user.Firstname,
Lastname = user.Lastname,
BirthDate = user.BirthDate,
Address = user.Address
Address = user.Address,
PreferredLanguage = user.PreferredLanguage
};
ret = userModel;

View File

@@ -13,6 +13,7 @@ internal class User : IdentityUser<Guid>
[MaxLength(2048)] public string? PortraitUrl { get; set; }
[MaxLength(256)] public string? GoogleId { get; set; }
[MaxLength(256)] public string? FacebookId { get; set; }
[MaxLength(8)] public string PreferredLanguage { get; set; } = "en";
[MaxLength(44)] public string? RefreshToken { get; set; }
public DateTime RefreshTokenExpiryTime { get; set; }
public DateTimeOffset? LastAuthenticatedAt { get; set; }

View File

@@ -0,0 +1,57 @@
using FastEndpoints;
using Microsoft.AspNetCore.Identity;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Identity.Data;
namespace Socialize.Api.Modules.Identity.Handlers;
[PublicAPI]
internal record ChangePreferredLanguageRequest(string PreferredLanguage);
[PublicAPI]
internal class ChangePreferredLanguageValidator : Validator<ChangePreferredLanguageRequest>
{
public ChangePreferredLanguageValidator()
{
RuleFor(x => x.PreferredLanguage)
.Must(value => value is "en" or "fr")
.WithMessage("Preferred language must be en or fr.");
}
}
[PublicAPI]
internal class ChangePreferredLanguageHandler(UserManager userManager)
: Endpoint<ChangePreferredLanguageRequest>
{
public override void Configure()
{
Post("/api/users/preferred-language");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
ChangePreferredLanguageRequest request,
CancellationToken ct)
{
User? user = await userManager.FindByIdAsync(HttpContext.User.GetUserId().ToString());
if (user is null)
{
await SendNotFoundAsync(ct);
return;
}
user.PreferredLanguage = request.PreferredLanguage;
IdentityResult result = await userManager.UpdateAsync(user);
if (result.Succeeded)
{
await SendOkAsync(ct);
}
else
{
await SendUnauthorizedAsync(ct);
}
}
}

View File

@@ -74,6 +74,7 @@ internal class GetCurrentUserQueryHandler(
Email = userModel.Email,
BirthDate = userModel.BirthDate,
Address = userModel.Address,
PreferredLanguage = userModel.PreferredLanguage,
UserRoles = roles
},
ct);

View File

@@ -17,4 +17,5 @@ internal class UserDto
public string? PhoneNumber { get; init; }
public DateTime? BirthDate { get; init; }
public string? Address { get; init; }
public string PreferredLanguage { get; init; } = "en";
}

View File

@@ -12,4 +12,5 @@ internal class UserModel
public string? PhoneNumber { get; init; }
public DateTime? BirthDate { get; init; }
public string? Address { get; init; }
public string PreferredLanguage { get; init; } = "en";
}

View File

@@ -46,6 +46,8 @@ internal record ReleaseUpdateUnreadSummaryDto(
int ImportantUnreadCount,
IReadOnlyCollection<ReleaseUpdateDto> Updates);
internal record ReleaseUpdateDigestSendResultDto(int SentCount);
internal static class ReleaseUpdateDtoMapper
{
public static ReleaseUpdateDto ToDto(this ReleaseUpdate update, bool isRead)

View File

@@ -0,0 +1,28 @@
using FastEndpoints;
using Socialize.Api.Modules.Identity.Contracts;
using Socialize.Api.Modules.ReleaseCommunications.Contracts;
using Socialize.Api.Modules.ReleaseCommunications.Services;
namespace Socialize.Api.Modules.ReleaseCommunications.Handlers;
internal class ForceDeveloperReleaseUpdateDigestEmailsHandler(ReleaseUpdateEmailService emailService)
: EndpointWithoutRequest<ReleaseUpdateDigestSendResultDto>
{
public override void Configure()
{
Post("/api/developer/release-update-email-digests/force");
Roles(KnownRoles.Developer);
Options(o => o.WithTags("Release Communications"));
}
public override async Task HandleAsync(CancellationToken ct)
{
int sentCount = await emailService.SendDueDigestEmailsAsync(
TimeSpan.Zero,
TimeSpan.Zero,
force: true,
ct: ct);
await SendOkAsync(new ReleaseUpdateDigestSendResultDto(sentCount), ct);
}
}

View File

@@ -40,7 +40,8 @@ internal sealed class ReleaseUpdateEmailDigestBackgroundService(
int sentCount = await emailService.SendDueDigestEmailsAsync(
TimeSpan.FromHours(options.Value.InactiveHoursBeforeDigest),
TimeSpan.FromHours(options.Value.DigestIntervalHours),
stoppingToken);
force: false,
ct: stoppingToken);
if (sentCount > 0 && logger.IsEnabled(LogLevel.Information))
{
logger.LogInformation("Sent {SentCount} release update digest emails.", sentCount);

View File

@@ -19,6 +19,7 @@ internal class ReleaseUpdateEmailService(
public async Task<int> SendDueDigestEmailsAsync(
TimeSpan inactiveThreshold,
TimeSpan sendInterval,
bool force,
CancellationToken ct)
{
DateTimeOffset now = DateTimeOffset.UtcNow;
@@ -30,7 +31,7 @@ internal class ReleaseUpdateEmailService(
foreach (User user in ownerUsers)
{
if (string.IsNullOrWhiteSpace(user.Email) ||
!ReleaseUpdateEmailRules.IsInactive(user.LastAuthenticatedAt, inactiveBefore))
(!force && !ReleaseUpdateEmailRules.IsInactive(user.LastAuthenticatedAt, inactiveBefore)))
{
continue;
}
@@ -40,7 +41,7 @@ internal class ReleaseUpdateEmailService(
.OrderByDescending(receipt => receipt.SentAt)
.Select(receipt => (DateTimeOffset?)receipt.SentAt)
.FirstOrDefaultAsync(ct);
if (!ReleaseUpdateEmailRules.CanSendDigest(lastDigestSentAt, lastSentBefore))
if (!force && !ReleaseUpdateEmailRules.CanSendDigest(lastDigestSentAt, lastSentBefore))
{
continue;
}
@@ -61,8 +62,8 @@ internal class ReleaseUpdateEmailService(
await emailSender.SendEmailAsync(
user.Email,
"What's new in Socialize",
BuildDigestEmail(unreadUpdates));
GetDigestSubject(user.PreferredLanguage),
BuildDigestEmail(unreadUpdates, user.PreferredLanguage));
dbContext.ReleaseUpdateEmailDigestReceipts.Add(new ReleaseUpdateEmailDigestReceipt
{
@@ -86,25 +87,41 @@ internal class ReleaseUpdateEmailService(
.ToListAsync(ct);
}
private string BuildDigestEmail(IReadOnlyCollection<ReleaseUpdate> updates)
private string BuildDigestEmail(IReadOnlyCollection<ReleaseUpdate> updates, string? preferredLanguage)
{
bool useFrench = IsFrench(preferredLanguage);
string updateUrl = $"{websiteOptions.Value.FrontendBaseUrl.TrimEnd('/')}/app/updates";
string listItems = string.Join(
Environment.NewLine,
updates.Select(update => $"""
<li>
<strong>{HtmlEncode(update.Title)}</strong><br>{HtmlEncode(update.Summary)}<br>
<strong>{HtmlEncode(update.TitleFr)}</strong><br>{HtmlEncode(update.SummaryFr)}
<strong>{HtmlEncode(useFrench ? update.TitleFr : update.Title)}</strong><br>
{HtmlEncode(useFrench ? update.SummaryFr : update.Summary)}
</li>
"""));
string heading = useFrench ? "Nouveautes dans Socialize" : "What's new in Socialize";
string linkText = useFrench ? "Ouvrir les nouveautes" : "Open What's New";
return $"""
<h1>What's new in Socialize</h1>
<h1>{HtmlEncode(heading)}</h1>
<ul>{listItems}</ul>
<p><a href="{HtmlEncode(updateUrl)}">Open What's New</a></p>
<p><a href="{HtmlEncode(updateUrl)}">{HtmlEncode(linkText)}</a></p>
""";
}
private static string GetDigestSubject(string? preferredLanguage)
{
return IsFrench(preferredLanguage)
? "Nouveautes dans Socialize"
: "What's new in Socialize";
}
private static bool IsFrench(string? preferredLanguage)
{
return string.Equals(preferredLanguage, "fr", StringComparison.OrdinalIgnoreCase);
}
private static string HtmlEncode(string? value)
{
return WebUtility.HtmlEncode(value ?? string.Empty);