feat: add release digest controls
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,7 @@ internal class GetCurrentUserQueryHandler(
|
||||
Email = userModel.Email,
|
||||
BirthDate = userModel.BirthDate,
|
||||
Address = userModel.Address,
|
||||
PreferredLanguage = userModel.PreferredLanguage,
|
||||
UserRoles = roles
|
||||
},
|
||||
ct);
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user