feat: add release communications
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
using System.Net;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Infrastructure.Configuration;
|
||||
using Socialize.Api.Infrastructure.Emailer.Contracts;
|
||||
using Socialize.Api.Modules.Identity.Contracts;
|
||||
using Socialize.Api.Modules.Identity.Data;
|
||||
using Socialize.Api.Modules.Organizations.Services;
|
||||
using Socialize.Api.Modules.ReleaseCommunications.Contracts;
|
||||
using Socialize.Api.Modules.ReleaseCommunications.Data;
|
||||
|
||||
namespace Socialize.Api.Modules.ReleaseCommunications.Services;
|
||||
|
||||
internal class ReleaseUpdateEmailService(
|
||||
AppDbContext dbContext,
|
||||
UserManager userManager,
|
||||
IEmailSender emailSender,
|
||||
IOptionsSnapshot<WebsiteOptions> websiteOptions)
|
||||
{
|
||||
public async Task<ReleaseUpdateEmailSendResultDto> SendManualUpdateEmailAsync(
|
||||
ReleaseUpdate update,
|
||||
Guid senderUserId,
|
||||
bool testMode,
|
||||
bool confirmResend,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (update.Status != ReleaseUpdateStatus.Published)
|
||||
{
|
||||
throw new InvalidOperationException("Only published release updates can be emailed.");
|
||||
}
|
||||
|
||||
if (!testMode && update.ManualEmailSentAt.HasValue && !confirmResend)
|
||||
{
|
||||
throw new InvalidOperationException("This release update was already emailed. Confirm resend to send it again.");
|
||||
}
|
||||
|
||||
IReadOnlyCollection<User> recipients = testMode
|
||||
? await GetTestRecipientsAsync(senderUserId, ct)
|
||||
: await GetAudienceRecipientsAsync(update.Audience, ct);
|
||||
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
foreach (User recipient in recipients.Where(recipient => !string.IsNullOrWhiteSpace(recipient.Email)))
|
||||
{
|
||||
await emailSender.SendEmailAsync(
|
||||
recipient.Email!,
|
||||
$"What's new in Socialize: {update.Title}",
|
||||
BuildSingleUpdateEmail(update));
|
||||
}
|
||||
|
||||
if (!testMode)
|
||||
{
|
||||
update.ManualEmailSentByUserId = senderUserId;
|
||||
update.ManualEmailSentAt = now;
|
||||
update.ManualEmailAudience = update.Audience.ToString();
|
||||
update.ManualEmailRecipientCount = recipients.Count;
|
||||
update.UpdatedAt = now;
|
||||
}
|
||||
|
||||
return new ReleaseUpdateEmailSendResultDto(recipients.Count, now, testMode);
|
||||
}
|
||||
|
||||
public async Task<int> SendDueDigestEmailsAsync(
|
||||
TimeSpan inactiveThreshold,
|
||||
TimeSpan sendInterval,
|
||||
CancellationToken ct)
|
||||
{
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
DateTimeOffset inactiveBefore = now.Subtract(inactiveThreshold);
|
||||
DateTimeOffset lastSentBefore = now.Subtract(sendInterval);
|
||||
|
||||
List<User> ownerUsers = await GetAudienceRecipientsAsync(ReleaseUpdateAudience.OrganizationOwners, ct);
|
||||
int sentCount = 0;
|
||||
foreach (User user in ownerUsers)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(user.Email) ||
|
||||
!ReleaseUpdateEmailRules.IsInactive(user.LastAuthenticatedAt, inactiveBefore))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
DateTimeOffset? lastDigestSentAt = await dbContext.ReleaseUpdateEmailDigestReceipts
|
||||
.Where(receipt => receipt.UserId == user.Id)
|
||||
.OrderByDescending(receipt => receipt.SentAt)
|
||||
.Select(receipt => (DateTimeOffset?)receipt.SentAt)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (!ReleaseUpdateEmailRules.CanSendDigest(lastDigestSentAt, lastSentBefore))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
ReleaseUpdateAudienceContext audienceContext = await ReleaseUpdateVisibility.GetAudienceContextAsync(
|
||||
dbContext,
|
||||
new ClaimsPrincipal(new ClaimsIdentity()),
|
||||
user.Id,
|
||||
ct);
|
||||
|
||||
List<ReleaseUpdate> unreadUpdates = await dbContext.ReleaseUpdates
|
||||
.VisibleTo(audienceContext)
|
||||
.Where(update => !dbContext.ReleaseUpdateReadReceipts.Any(receipt =>
|
||||
receipt.ReleaseUpdateId == update.Id &&
|
||||
receipt.UserId == user.Id))
|
||||
.OrderByDescending(update => update.PublishedAt)
|
||||
.Take(10)
|
||||
.ToListAsync(ct);
|
||||
|
||||
if (unreadUpdates.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
await emailSender.SendEmailAsync(
|
||||
user.Email,
|
||||
"What's new in Socialize",
|
||||
BuildDigestEmail(unreadUpdates));
|
||||
|
||||
dbContext.ReleaseUpdateEmailDigestReceipts.Add(new ReleaseUpdateEmailDigestReceipt
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = user.Id,
|
||||
SentAt = now,
|
||||
UpdateCount = unreadUpdates.Count,
|
||||
});
|
||||
sentCount++;
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
return sentCount;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyCollection<User>> GetTestRecipientsAsync(Guid senderUserId, CancellationToken ct)
|
||||
{
|
||||
User? sender = await userManager.Users.SingleOrDefaultAsync(user => user.Id == senderUserId, ct);
|
||||
return sender is null ? [] : [sender];
|
||||
}
|
||||
|
||||
private async Task<List<User>> GetAudienceRecipientsAsync(ReleaseUpdateAudience audience, CancellationToken ct)
|
||||
{
|
||||
IQueryable<User> query = userManager.Users.Where(user => user.EmailConfirmed && user.Email != null);
|
||||
|
||||
if (audience == ReleaseUpdateAudience.Developers)
|
||||
{
|
||||
IList<User> developers = await userManager.GetUsersInRoleAsync(KnownRoles.Developer);
|
||||
return developers.Where(user => user.EmailConfirmed && !string.IsNullOrWhiteSpace(user.Email)).ToList();
|
||||
}
|
||||
|
||||
if (audience == ReleaseUpdateAudience.OrganizationOwners)
|
||||
{
|
||||
Guid[] ownerUserIds = await dbContext.Organizations
|
||||
.Select(organization => organization.OwnerUserId)
|
||||
.Concat(dbContext.OrganizationMemberships
|
||||
.Where(membership => membership.Role == OrganizationRoles.Owner)
|
||||
.Select(membership => membership.UserId))
|
||||
.Distinct()
|
||||
.ToArrayAsync(ct);
|
||||
|
||||
query = query.Where(user => ownerUserIds.Contains(user.Id));
|
||||
}
|
||||
|
||||
return await query.OrderBy(user => user.Email).ToListAsync(ct);
|
||||
}
|
||||
|
||||
private string BuildSingleUpdateEmail(ReleaseUpdate update)
|
||||
{
|
||||
string updateUrl = $"{websiteOptions.Value.FrontendBaseUrl.TrimEnd('/')}/app/updates?updateId={update.Id}";
|
||||
return $"""
|
||||
<h1>{HtmlEncode(update.Title)}</h1>
|
||||
<p><strong>{HtmlEncode(update.Category.ToString())}</strong></p>
|
||||
<p>{HtmlEncode(update.Summary)}</p>
|
||||
{FormatBody(update.Body)}
|
||||
<p><a href="{HtmlEncode(updateUrl)}">Open What's New</a></p>
|
||||
""";
|
||||
}
|
||||
|
||||
private string BuildDigestEmail(IReadOnlyCollection<ReleaseUpdate> updates)
|
||||
{
|
||||
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)}</li>"));
|
||||
|
||||
return $"""
|
||||
<h1>What's new in Socialize</h1>
|
||||
<ul>{listItems}</ul>
|
||||
<p><a href="{HtmlEncode(updateUrl)}">Open What's New</a></p>
|
||||
""";
|
||||
}
|
||||
|
||||
private static string FormatBody(string? body)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(body)
|
||||
? string.Empty
|
||||
: $"<p>{HtmlEncode(body).Replace(Environment.NewLine, "<br>", StringComparison.Ordinal)}</p>";
|
||||
}
|
||||
|
||||
private static string HtmlEncode(string? value)
|
||||
{
|
||||
return WebUtility.HtmlEncode(value ?? string.Empty);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user