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

This commit is contained in:
2026-05-07 21:00:59 -04:00
parent 7a8a0a44bf
commit b6eb348605
61 changed files with 8594 additions and 4 deletions

View File

@@ -15,5 +15,6 @@ internal class User : IdentityUser<Guid>
[MaxLength(256)] public string? FacebookId { get; set; }
[MaxLength(44)] public string? RefreshToken { get; set; }
public DateTime RefreshTokenExpiryTime { get; set; }
public DateTimeOffset? LastAuthenticatedAt { get; set; }
public string Fullname => $"{Lastname}, {Firstname}";
}

View File

@@ -71,6 +71,7 @@ internal class LoginHandler(
// Generate a new refresh token
user.RefreshToken = RefreshTokenGenerator.Next();
user.RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime);
user.LastAuthenticatedAt = DateTimeOffset.UtcNow;
await userManager.UpdateAsync(user);
// Generate JWT token

View File

@@ -99,7 +99,8 @@ internal class LoginWithFacebookHandler(
Lastname = userInfo.Name.Split(' ').Skip(1).FirstOrDefault() ?? "",
Alias = userInfo.Name,
PortraitUrl = userInfo.Picture.Picture.Url,
FacebookId = userInfo.Id // Storing Facebook ID
FacebookId = userInfo.Id, // Storing Facebook ID
LastAuthenticatedAt = DateTimeOffset.UtcNow,
};
IdentityResult result = await userManager.CreateAsync(
@@ -124,6 +125,7 @@ internal class LoginWithFacebookHandler(
// Store refresh token in user's properties
user.RefreshToken = refreshToken;
user.RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime);
user.LastAuthenticatedAt = DateTimeOffset.UtcNow;
await userManager.UpdateAsync(user);
string accessToken = await accessTokenFactory.CreateAsync(user);

View File

@@ -106,7 +106,8 @@ internal class LoginWithGoogleHandler(
PortraitUrl = userInfo.Picture,
GoogleId = userInfo.Id,
RefreshToken = refreshToken,
RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime)
RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime),
LastAuthenticatedAt = DateTimeOffset.UtcNow,
};
IdentityResult result = await userManager.CreateAsync(
@@ -128,6 +129,7 @@ internal class LoginWithGoogleHandler(
// Generate the new refresh token
user.RefreshToken = RefreshTokenGenerator.Next();
user.RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime);
user.LastAuthenticatedAt = DateTimeOffset.UtcNow;
await userManager.UpdateAsync(user);
string accessToken = await accessTokenFactory.CreateAsync(user);

View File

@@ -53,6 +53,7 @@ internal class RefreshTokenHandler(
// Update refresh token expiry time
user.RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime);
user.LastAuthenticatedAt = DateTimeOffset.UtcNow;
await userManager.UpdateAsync(user);
// Generate a new access token

View File

@@ -0,0 +1,10 @@
namespace Socialize.Api.Modules.ReleaseCommunications.Configuration;
internal class ReleaseCommunicationEmailOptions
{
public const string SectionName = "ReleaseCommunications:Email";
public bool DigestEnabled { get; set; }
public int InactiveHoursBeforeDigest { get; set; } = 24;
public int DigestIntervalHours { get; set; } = 24;
}

View File

@@ -0,0 +1,9 @@
namespace Socialize.Api.Modules.ReleaseCommunications.Configuration;
internal class ReleaseCommunicationRepositoryOptions
{
public const string SectionName = "ReleaseCommunications:Repository";
public string? RepositoryUrl { get; set; }
public string? AccessToken { get; set; }
}

View File

@@ -0,0 +1,109 @@
using Socialize.Api.Modules.ReleaseCommunications.Data;
namespace Socialize.Api.Modules.ReleaseCommunications.Contracts;
internal record ReleaseUpdateDto(
Guid Id,
string Title,
string Summary,
string? Body,
string Category,
string Importance,
string Audience,
string Status,
string? DeploymentLabel,
string? BuildVersion,
string? CommitRange,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt,
DateTimeOffset? PublishedAt,
DateTimeOffset? ArchivedAt,
Guid? ManualEmailSentByUserId,
DateTimeOffset? ManualEmailSentAt,
string? ManualEmailAudience,
int? ManualEmailRecipientCount,
bool IsRead);
internal record ReleaseCommitDto(
string Sha,
string ShortSha,
string Subject,
string? AuthorName,
string? AuthorEmail,
DateTimeOffset? AuthoredAt,
DateTimeOffset? CommittedAt,
string? SourceBranch,
string? DeploymentLabel,
string? ExternalUrl,
string CommunicationStatus,
Guid? ReleaseUpdateId,
DateTimeOffset ImportedAt,
DateTimeOffset UpdatedAt);
internal record ReleaseCommitImportResultDto(
int ImportedCount,
int UpdatedCount,
int SkippedCount,
IReadOnlyCollection<ReleaseCommitDto> Commits);
internal record ReleaseUpdateEmailSendResultDto(
int RecipientCount,
DateTimeOffset SentAt,
bool TestMode);
internal record ReleaseUpdateUnreadSummaryDto(
int UnreadCount,
int ImportantUnreadCount,
IReadOnlyCollection<ReleaseUpdateDto> Updates);
internal static class ReleaseUpdateDtoMapper
{
public static ReleaseUpdateDto ToDto(this ReleaseUpdate update, bool isRead)
{
return new ReleaseUpdateDto(
update.Id,
update.Title,
update.Summary,
update.Body,
ToDisplayString(update.Category),
update.Importance.ToString(),
update.Audience.ToString(),
update.Status.ToString(),
update.DeploymentLabel,
update.BuildVersion,
update.CommitRange,
update.CreatedAt,
update.UpdatedAt,
update.PublishedAt,
update.ArchivedAt,
update.ManualEmailSentByUserId,
update.ManualEmailSentAt,
update.ManualEmailAudience,
update.ManualEmailRecipientCount,
isRead);
}
public static ReleaseCommitDto ToDto(this ReleaseCommit commit)
{
return new ReleaseCommitDto(
commit.Sha,
commit.ShortSha,
commit.Subject,
commit.AuthorName,
commit.AuthorEmail,
commit.AuthoredAt,
commit.CommittedAt,
commit.SourceBranch,
commit.DeploymentLabel,
commit.ExternalUrl,
commit.CommunicationStatus.ToString(),
commit.ReleaseUpdateId,
commit.ImportedAt,
commit.UpdatedAt);
}
private static string ToDisplayString(ReleaseUpdateCategory category)
{
return category == ReleaseUpdateCategory.BreakingChange ? "Breaking Change" : category.ToString();
}
}

View File

@@ -0,0 +1,20 @@
namespace Socialize.Api.Modules.ReleaseCommunications.Data;
internal class ReleaseCommit
{
public string Sha { get; set; } = string.Empty;
public string ShortSha { get; set; } = string.Empty;
public string Subject { get; set; } = string.Empty;
public string? AuthorName { get; set; }
public string? AuthorEmail { get; set; }
public DateTimeOffset? AuthoredAt { get; set; }
public DateTimeOffset? CommittedAt { get; set; }
public string? SourceBranch { get; set; }
public string? DeploymentLabel { get; set; }
public string? ExternalUrl { get; set; }
public ReleaseCommitCommunicationStatus CommunicationStatus { get; set; }
public Guid? ReleaseUpdateId { get; set; }
public DateTimeOffset ImportedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public ReleaseUpdate? ReleaseUpdate { get; set; }
}

View File

@@ -0,0 +1,9 @@
namespace Socialize.Api.Modules.ReleaseCommunications.Data;
internal enum ReleaseCommitCommunicationStatus
{
Unreviewed,
Linked,
InternalOnly,
Ignored,
}

View File

@@ -0,0 +1,78 @@
using Microsoft.EntityFrameworkCore;
namespace Socialize.Api.Modules.ReleaseCommunications.Data;
internal static class ReleaseCommunicationsModelConfiguration
{
public static ModelBuilder ConfigureReleaseCommunicationsModule(this ModelBuilder modelBuilder)
{
modelBuilder.Entity<ReleaseUpdate>(releaseUpdate =>
{
releaseUpdate.ToTable("ReleaseUpdates");
releaseUpdate.HasKey(x => x.Id);
releaseUpdate.Property(x => x.Title).HasMaxLength(160).IsRequired();
releaseUpdate.Property(x => x.Summary).HasMaxLength(512).IsRequired();
releaseUpdate.Property(x => x.Body).HasMaxLength(8000);
releaseUpdate.Property(x => x.Category).HasConversion<string>().HasMaxLength(32).IsRequired();
releaseUpdate.Property(x => x.Importance).HasConversion<string>().HasMaxLength(32).IsRequired();
releaseUpdate.Property(x => x.Audience).HasConversion<string>().HasMaxLength(32).IsRequired();
releaseUpdate.Property(x => x.Status).HasConversion<string>().HasMaxLength(32).IsRequired();
releaseUpdate.Property(x => x.DeploymentLabel).HasMaxLength(128);
releaseUpdate.Property(x => x.BuildVersion).HasMaxLength(128);
releaseUpdate.Property(x => x.CommitRange).HasMaxLength(256);
releaseUpdate.Property(x => x.ManualEmailAudience).HasMaxLength(64);
releaseUpdate.Property(x => x.CreatedAt).ValueGeneratedOnAdd().HasDefaultValueSql("CURRENT_TIMESTAMP");
releaseUpdate.HasIndex(x => x.Status);
releaseUpdate.HasIndex(x => x.Audience);
releaseUpdate.HasIndex(x => x.PublishedAt);
releaseUpdate.HasIndex(x => x.CreatedByUserId);
});
modelBuilder.Entity<ReleaseUpdateReadReceipt>(receipt =>
{
receipt.ToTable("ReleaseUpdateReadReceipts");
receipt.HasKey(x => x.Id);
receipt.Property(x => x.ReadAt).ValueGeneratedOnAdd().HasDefaultValueSql("CURRENT_TIMESTAMP");
receipt.HasIndex(x => x.UserId);
receipt.HasIndex(x => new { x.ReleaseUpdateId, x.UserId }).IsUnique();
receipt.HasOne(x => x.ReleaseUpdate)
.WithMany(x => x.ReadReceipts)
.HasForeignKey(x => x.ReleaseUpdateId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<ReleaseCommit>(commit =>
{
commit.ToTable("ReleaseCommits");
commit.HasKey(x => x.Sha);
commit.Property(x => x.Sha).HasMaxLength(64).IsRequired();
commit.Property(x => x.ShortSha).HasMaxLength(16).IsRequired();
commit.Property(x => x.Subject).HasMaxLength(512).IsRequired();
commit.Property(x => x.AuthorName).HasMaxLength(256);
commit.Property(x => x.AuthorEmail).HasMaxLength(256);
commit.Property(x => x.SourceBranch).HasMaxLength(256);
commit.Property(x => x.DeploymentLabel).HasMaxLength(128);
commit.Property(x => x.ExternalUrl).HasMaxLength(2048);
commit.Property(x => x.CommunicationStatus).HasConversion<string>().HasMaxLength(32).IsRequired();
commit.Property(x => x.ImportedAt).ValueGeneratedOnAdd().HasDefaultValueSql("CURRENT_TIMESTAMP");
commit.HasIndex(x => x.CommunicationStatus);
commit.HasIndex(x => x.ReleaseUpdateId);
commit.HasIndex(x => x.CommittedAt);
commit.HasOne(x => x.ReleaseUpdate)
.WithMany()
.HasForeignKey(x => x.ReleaseUpdateId)
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity<ReleaseUpdateEmailDigestReceipt>(receipt =>
{
receipt.ToTable("ReleaseUpdateEmailDigestReceipts");
receipt.HasKey(x => x.Id);
receipt.Property(x => x.SentAt).ValueGeneratedOnAdd().HasDefaultValueSql("CURRENT_TIMESTAMP");
receipt.HasIndex(x => x.UserId);
receipt.HasIndex(x => x.SentAt);
});
return modelBuilder;
}
}

View File

@@ -0,0 +1,26 @@
namespace Socialize.Api.Modules.ReleaseCommunications.Data;
internal class ReleaseUpdate
{
public Guid Id { get; set; }
public string Title { get; set; } = string.Empty;
public string Summary { get; set; } = string.Empty;
public string? Body { get; set; }
public ReleaseUpdateCategory Category { get; set; }
public ReleaseUpdateImportance Importance { get; set; }
public ReleaseUpdateAudience Audience { get; set; }
public ReleaseUpdateStatus Status { get; set; }
public string? DeploymentLabel { get; set; }
public string? BuildVersion { get; set; }
public string? CommitRange { get; set; }
public Guid CreatedByUserId { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public DateTimeOffset? PublishedAt { get; set; }
public DateTimeOffset? ArchivedAt { get; set; }
public Guid? ManualEmailSentByUserId { get; set; }
public DateTimeOffset? ManualEmailSentAt { get; set; }
public string? ManualEmailAudience { get; set; }
public int? ManualEmailRecipientCount { get; set; }
public ICollection<ReleaseUpdateReadReceipt> ReadReceipts { get; } = new List<ReleaseUpdateReadReceipt>();
}

View File

@@ -0,0 +1,8 @@
namespace Socialize.Api.Modules.ReleaseCommunications.Data;
internal enum ReleaseUpdateAudience
{
Everyone,
OrganizationOwners,
Developers,
}

View File

@@ -0,0 +1,9 @@
namespace Socialize.Api.Modules.ReleaseCommunications.Data;
internal enum ReleaseUpdateCategory
{
Feature,
Improvement,
Fix,
BreakingChange,
}

View File

@@ -0,0 +1,9 @@
namespace Socialize.Api.Modules.ReleaseCommunications.Data;
internal class ReleaseUpdateEmailDigestReceipt
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public DateTimeOffset SentAt { get; set; }
public int UpdateCount { get; set; }
}

View File

@@ -0,0 +1,7 @@
namespace Socialize.Api.Modules.ReleaseCommunications.Data;
internal enum ReleaseUpdateImportance
{
Normal,
Important,
}

View File

@@ -0,0 +1,10 @@
namespace Socialize.Api.Modules.ReleaseCommunications.Data;
internal class ReleaseUpdateReadReceipt
{
public Guid Id { get; set; }
public Guid ReleaseUpdateId { get; set; }
public Guid UserId { get; set; }
public DateTimeOffset ReadAt { get; set; }
public ReleaseUpdate ReleaseUpdate { get; set; } = null!;
}

View File

@@ -0,0 +1,8 @@
namespace Socialize.Api.Modules.ReleaseCommunications.Data;
internal enum ReleaseUpdateStatus
{
Draft,
Published,
Archived,
}

View File

@@ -0,0 +1,44 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Modules.Identity.Contracts;
using Socialize.Api.Modules.ReleaseCommunications.Contracts;
using Socialize.Api.Modules.ReleaseCommunications.Data;
namespace Socialize.Api.Modules.ReleaseCommunications.Handlers;
internal class ArchiveDeveloperReleaseUpdateHandler(AppDbContext dbContext)
: EndpointWithoutRequest<ReleaseUpdateDto>
{
public override void Configure()
{
Post("/api/developer/release-updates/{id}/archive");
Roles(KnownRoles.Developer);
Options(o => o.WithTags("Release Communications"));
}
public override async Task HandleAsync(CancellationToken ct)
{
Guid id = Route<Guid>("id");
ReleaseUpdate? update = await dbContext.ReleaseUpdates.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
if (update is null)
{
await SendNotFoundAsync(ct);
return;
}
if (update.Status == ReleaseUpdateStatus.Archived)
{
await SendOkAsync(update.ToDto(false), ct);
return;
}
DateTimeOffset now = DateTimeOffset.UtcNow;
update.Status = ReleaseUpdateStatus.Archived;
update.ArchivedAt = now;
update.UpdatedAt = now;
await dbContext.SaveChangesAsync(ct);
await SendOkAsync(update.ToDto(false), ct);
}
}

View File

@@ -0,0 +1,115 @@
using FastEndpoints;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Identity.Contracts;
using Socialize.Api.Modules.ReleaseCommunications.Contracts;
using Socialize.Api.Modules.ReleaseCommunications.Data;
using Socialize.Api.Modules.ReleaseCommunications.Services;
namespace Socialize.Api.Modules.ReleaseCommunications.Handlers;
internal record CreateDeveloperReleaseUpdateRequest(
string Title,
string Summary,
string? Body,
string Category,
string Importance,
string Audience,
string? DeploymentLabel,
string? BuildVersion,
string? CommitRange);
internal class CreateDeveloperReleaseUpdateRequestValidator
: Validator<CreateDeveloperReleaseUpdateRequest>
{
public CreateDeveloperReleaseUpdateRequestValidator()
{
RuleFor(x => x.Title).NotEmpty().MaximumLength(160);
RuleFor(x => x.Summary).NotEmpty().MaximumLength(512);
RuleFor(x => x.Body).MaximumLength(8000);
RuleFor(x => x.Category).NotEmpty().MaximumLength(32);
RuleFor(x => x.Importance).NotEmpty().MaximumLength(32);
RuleFor(x => x.Audience).NotEmpty().MaximumLength(32);
RuleFor(x => x.DeploymentLabel).MaximumLength(128);
RuleFor(x => x.BuildVersion).MaximumLength(128);
RuleFor(x => x.CommitRange).MaximumLength(256);
}
}
internal class CreateDeveloperReleaseUpdateHandler(AppDbContext dbContext)
: Endpoint<CreateDeveloperReleaseUpdateRequest, ReleaseUpdateDto>
{
public override void Configure()
{
Post("/api/developer/release-updates");
Roles(KnownRoles.Developer);
Options(o => o.WithTags("Release Communications"));
}
public override async Task HandleAsync(CreateDeveloperReleaseUpdateRequest request, CancellationToken ct)
{
if (!TryParseRequest(request, out ReleaseUpdateCategory category, out ReleaseUpdateImportance importance, out ReleaseUpdateAudience audience))
{
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
DateTimeOffset now = DateTimeOffset.UtcNow;
ReleaseUpdate update = new()
{
Id = Guid.NewGuid(),
Title = request.Title.Trim(),
Summary = request.Summary.Trim(),
Body = NormalizeOptional(request.Body),
Category = category,
Importance = importance,
Audience = audience,
Status = ReleaseUpdateStatus.Draft,
DeploymentLabel = NormalizeOptional(request.DeploymentLabel),
BuildVersion = NormalizeOptional(request.BuildVersion),
CommitRange = NormalizeOptional(request.CommitRange),
CreatedByUserId = User.GetUserId(),
CreatedAt = now,
UpdatedAt = now,
};
dbContext.ReleaseUpdates.Add(update);
await dbContext.SaveChangesAsync(ct);
await SendAsync(update.ToDto(false), StatusCodes.Status201Created, ct);
}
private bool TryParseRequest(
CreateDeveloperReleaseUpdateRequest request,
out ReleaseUpdateCategory category,
out ReleaseUpdateImportance importance,
out ReleaseUpdateAudience audience)
{
bool isValid = true;
if (!ReleaseUpdateRules.TryParseCategory(request.Category, out category))
{
AddError(x => x.Category, "The selected release update category is not valid.");
isValid = false;
}
if (!ReleaseUpdateRules.TryParseImportance(request.Importance, out importance))
{
AddError(x => x.Importance, "The selected release update importance is not valid.");
isValid = false;
}
if (!ReleaseUpdateRules.TryParseAudience(request.Audience, out audience))
{
AddError(x => x.Audience, "The selected release update audience is not valid.");
isValid = false;
}
return isValid;
}
private static string? NormalizeOptional(string? value)
{
string? normalized = value?.Trim();
return string.IsNullOrWhiteSpace(normalized) ? null : normalized;
}
}

View File

@@ -0,0 +1,33 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Modules.Identity.Contracts;
using Socialize.Api.Modules.ReleaseCommunications.Contracts;
using Socialize.Api.Modules.ReleaseCommunications.Data;
namespace Socialize.Api.Modules.ReleaseCommunications.Handlers;
internal class GetDeveloperReleaseUpdateHandler(AppDbContext dbContext)
: EndpointWithoutRequest<ReleaseUpdateDto>
{
public override void Configure()
{
Get("/api/developer/release-updates/{id}");
Roles(KnownRoles.Developer);
Options(o => o.WithTags("Release Communications"));
}
public override async Task HandleAsync(CancellationToken ct)
{
Guid id = Route<Guid>("id");
ReleaseUpdate? update = await dbContext.ReleaseUpdates.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
if (update is null)
{
await SendNotFoundAsync(ct);
return;
}
await SendOkAsync(update.ToDto(false), ct);
}
}

View File

@@ -0,0 +1,42 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.ReleaseCommunications.Contracts;
using Socialize.Api.Modules.ReleaseCommunications.Data;
using Socialize.Api.Modules.ReleaseCommunications.Services;
namespace Socialize.Api.Modules.ReleaseCommunications.Handlers;
internal class GetUnreadReleaseUpdatesHandler(AppDbContext dbContext)
: EndpointWithoutRequest<ReleaseUpdateUnreadSummaryDto>
{
public override void Configure()
{
Get("/api/release-updates/unread");
Options(o => o.WithTags("Release Communications"));
}
public override async Task HandleAsync(CancellationToken ct)
{
Guid userId = User.GetUserId();
ReleaseUpdateAudienceContext audienceContext =
await ReleaseUpdateVisibility.GetAudienceContextAsync(dbContext, User, userId, ct);
List<ReleaseUpdate> unreadUpdates = await dbContext.ReleaseUpdates
.VisibleTo(audienceContext)
.Where(update => !dbContext.ReleaseUpdateReadReceipts.Any(receipt =>
receipt.ReleaseUpdateId == update.Id &&
receipt.UserId == userId))
.OrderByDescending(update => update.PublishedAt)
.ThenByDescending(update => update.CreatedAt)
.ToListAsync(ct);
await SendOkAsync(
new ReleaseUpdateUnreadSummaryDto(
unreadUpdates.Count,
unreadUpdates.Count(update => update.Importance == ReleaseUpdateImportance.Important),
unreadUpdates.Select(update => update.ToDto(false)).ToArray()),
ct);
}
}

View File

@@ -0,0 +1,131 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Socialize.Api.Data;
using Socialize.Api.Modules.Identity.Contracts;
using Socialize.Api.Modules.ReleaseCommunications.Configuration;
using Socialize.Api.Modules.ReleaseCommunications.Contracts;
using Socialize.Api.Modules.ReleaseCommunications.Data;
namespace Socialize.Api.Modules.ReleaseCommunications.Handlers;
internal record ImportDeveloperReleaseCommitDto(
string Sha,
string? ShortSha,
string Subject,
string? AuthorName,
string? AuthorEmail,
DateTimeOffset? AuthoredAt,
DateTimeOffset? CommittedAt,
string? SourceBranch,
string? DeploymentLabel,
string? ExternalUrl);
internal record ImportDeveloperReleaseCommitsRequest(
string? SinceSha,
string? UntilSha,
string? SourceBranch,
string? DeploymentLabel,
IReadOnlyCollection<ImportDeveloperReleaseCommitDto>? Commits);
internal class ImportDeveloperReleaseCommitsHandler(
AppDbContext dbContext,
IOptionsSnapshot<ReleaseCommunicationRepositoryOptions> repositoryOptions)
: Endpoint<ImportDeveloperReleaseCommitsRequest, ReleaseCommitImportResultDto>
{
public override void Configure()
{
Post("/api/developer/release-commits/import");
Roles(KnownRoles.Developer);
Options(o => o.WithTags("Release Communications"));
}
public override async Task HandleAsync(ImportDeveloperReleaseCommitsRequest request, CancellationToken ct)
{
if (request.Commits is not { Count: > 0 })
{
if (string.IsNullOrWhiteSpace(repositoryOptions.Value.RepositoryUrl))
{
AddError("ReleaseCommunications:Repository:RepositoryUrl is required before repository import can be used.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
AddError("Repository-backed commit import is not implemented yet. Submit a commit payload or configure the repository integration task.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
IReadOnlyCollection<ReleaseCommit> requestedCommits = request.Commits.Select(ToReleaseCommit).ToArray();
int imported = 0;
int updated = 0;
int skipped = 0;
List<ReleaseCommit> savedCommits = [];
foreach (ReleaseCommit requestedCommit in requestedCommits)
{
if (string.IsNullOrWhiteSpace(requestedCommit.Sha) || string.IsNullOrWhiteSpace(requestedCommit.Subject))
{
skipped++;
continue;
}
ReleaseCommit? existingCommit = await dbContext.ReleaseCommits.SingleOrDefaultAsync(
commit => commit.Sha == requestedCommit.Sha,
ct);
if (existingCommit is null)
{
dbContext.ReleaseCommits.Add(requestedCommit);
savedCommits.Add(requestedCommit);
imported++;
continue;
}
existingCommit.ShortSha = requestedCommit.ShortSha;
existingCommit.Subject = requestedCommit.Subject;
existingCommit.AuthorName = requestedCommit.AuthorName;
existingCommit.AuthorEmail = requestedCommit.AuthorEmail;
existingCommit.AuthoredAt = requestedCommit.AuthoredAt;
existingCommit.CommittedAt = requestedCommit.CommittedAt;
existingCommit.SourceBranch = requestedCommit.SourceBranch ?? existingCommit.SourceBranch;
existingCommit.DeploymentLabel = requestedCommit.DeploymentLabel ?? existingCommit.DeploymentLabel;
existingCommit.ExternalUrl = requestedCommit.ExternalUrl ?? existingCommit.ExternalUrl;
existingCommit.UpdatedAt = DateTimeOffset.UtcNow;
savedCommits.Add(existingCommit);
updated++;
}
await dbContext.SaveChangesAsync(ct);
await SendOkAsync(
new ReleaseCommitImportResultDto(imported, updated, skipped, savedCommits.Select(commit => commit.ToDto()).ToArray()),
ct);
}
private static ReleaseCommit ToReleaseCommit(ImportDeveloperReleaseCommitDto dto)
{
DateTimeOffset now = DateTimeOffset.UtcNow;
return new ReleaseCommit
{
Sha = dto.Sha.Trim(),
ShortSha = NormalizeOptional(dto.ShortSha) ?? dto.Sha.Trim()[..Math.Min(dto.Sha.Trim().Length, 12)],
Subject = dto.Subject.Trim(),
AuthorName = NormalizeOptional(dto.AuthorName),
AuthorEmail = NormalizeOptional(dto.AuthorEmail),
AuthoredAt = dto.AuthoredAt,
CommittedAt = dto.CommittedAt,
SourceBranch = NormalizeOptional(dto.SourceBranch),
DeploymentLabel = NormalizeOptional(dto.DeploymentLabel),
ExternalUrl = NormalizeOptional(dto.ExternalUrl),
CommunicationStatus = ReleaseCommitCommunicationStatus.Unreviewed,
ImportedAt = now,
UpdatedAt = now,
};
}
private static string? NormalizeOptional(string? value)
{
string? normalized = value?.Trim();
return string.IsNullOrWhiteSpace(normalized) ? null : normalized;
}
}

View File

@@ -0,0 +1,28 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Modules.Identity.Contracts;
using Socialize.Api.Modules.ReleaseCommunications.Contracts;
namespace Socialize.Api.Modules.ReleaseCommunications.Handlers;
internal class ListDeveloperReleaseCommitsHandler(AppDbContext dbContext)
: EndpointWithoutRequest<IReadOnlyCollection<ReleaseCommitDto>>
{
public override void Configure()
{
Get("/api/developer/release-commits");
Roles(KnownRoles.Developer);
Options(o => o.WithTags("Release Communications"));
}
public override async Task HandleAsync(CancellationToken ct)
{
List<ReleaseCommitDto> commits = await dbContext.ReleaseCommits
.OrderByDescending(commit => commit.CommittedAt ?? commit.ImportedAt)
.Select(commit => commit.ToDto())
.ToListAsync(ct);
await SendOkAsync(commits, ct);
}
}

View File

@@ -0,0 +1,29 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Modules.Identity.Contracts;
using Socialize.Api.Modules.ReleaseCommunications.Contracts;
using Socialize.Api.Modules.ReleaseCommunications.Data;
namespace Socialize.Api.Modules.ReleaseCommunications.Handlers;
internal class ListDeveloperReleaseUpdatesHandler(AppDbContext dbContext)
: EndpointWithoutRequest<IReadOnlyCollection<ReleaseUpdateDto>>
{
public override void Configure()
{
Get("/api/developer/release-updates");
Roles(KnownRoles.Developer);
Options(o => o.WithTags("Release Communications"));
}
public override async Task HandleAsync(CancellationToken ct)
{
List<ReleaseUpdate> updates = await dbContext.ReleaseUpdates
.OrderByDescending(update => update.PublishedAt ?? update.CreatedAt)
.ThenByDescending(update => update.CreatedAt)
.ToListAsync(ct);
await SendOkAsync(updates.Select(update => update.ToDto(false)).ToArray(), ct);
}
}

View File

@@ -0,0 +1,50 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.ReleaseCommunications.Contracts;
using Socialize.Api.Modules.ReleaseCommunications.Data;
using Socialize.Api.Modules.ReleaseCommunications.Services;
namespace Socialize.Api.Modules.ReleaseCommunications.Handlers;
internal class ListReleaseUpdatesHandler(AppDbContext dbContext)
: EndpointWithoutRequest<IReadOnlyCollection<ReleaseUpdateDto>>
{
public override void Configure()
{
Get("/api/release-updates");
Options(o => o.WithTags("Release Communications"));
}
public override async Task HandleAsync(CancellationToken ct)
{
Guid userId = User.GetUserId();
ReleaseUpdateAudienceContext audienceContext =
await ReleaseUpdateVisibility.GetAudienceContextAsync(dbContext, User, userId, ct);
List<ReleaseUpdate> updates = await dbContext.ReleaseUpdates
.VisibleTo(audienceContext)
.OrderByDescending(update => update.PublishedAt)
.ThenByDescending(update => update.CreatedAt)
.ToListAsync(ct);
HashSet<Guid> readUpdateIds = await GetReadUpdateIdsAsync(userId, updates.Select(update => update.Id), ct);
await SendOkAsync(
updates.Select(update => update.ToDto(readUpdateIds.Contains(update.Id))).ToArray(),
ct);
}
private async Task<HashSet<Guid>> GetReadUpdateIdsAsync(
Guid userId,
IEnumerable<Guid> updateIds,
CancellationToken ct)
{
Guid[] ids = updateIds.ToArray();
return await dbContext.ReleaseUpdateReadReceipts
.Where(receipt => receipt.UserId == userId && ids.Contains(receipt.ReleaseUpdateId))
.Select(receipt => receipt.ReleaseUpdateId)
.ToHashSetAsync(ct);
}
}

View File

@@ -0,0 +1,45 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.ReleaseCommunications.Data;
using Socialize.Api.Modules.ReleaseCommunications.Services;
namespace Socialize.Api.Modules.ReleaseCommunications.Handlers;
internal class MarkAllReleaseUpdatesReadHandler(AppDbContext dbContext)
: EndpointWithoutRequest
{
public override void Configure()
{
Post("/api/release-updates/read-all");
Options(o => o.WithTags("Release Communications"));
}
public override async Task HandleAsync(CancellationToken ct)
{
Guid userId = User.GetUserId();
ReleaseUpdateAudienceContext audienceContext =
await ReleaseUpdateVisibility.GetAudienceContextAsync(dbContext, User, userId, ct);
List<Guid> visibleUpdateIds = await dbContext.ReleaseUpdates
.VisibleTo(audienceContext)
.Select(update => update.Id)
.ToListAsync(ct);
HashSet<Guid> existingReadIds = await dbContext.ReleaseUpdateReadReceipts
.Where(receipt => receipt.UserId == userId && visibleUpdateIds.Contains(receipt.ReleaseUpdateId))
.Select(receipt => receipt.ReleaseUpdateId)
.ToHashSetAsync(ct);
dbContext.ReleaseUpdateReadReceipts.AddRange(
ReleaseUpdateReadState.CreateMissingReadReceipts(
userId,
visibleUpdateIds,
existingReadIds,
DateTimeOffset.UtcNow));
await dbContext.SaveChangesAsync(ct);
await SendNoContentAsync(ct);
}
}

View File

@@ -0,0 +1,53 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.ReleaseCommunications.Data;
using Socialize.Api.Modules.ReleaseCommunications.Services;
namespace Socialize.Api.Modules.ReleaseCommunications.Handlers;
internal class MarkReleaseUpdateReadHandler(AppDbContext dbContext)
: EndpointWithoutRequest
{
public override void Configure()
{
Post("/api/release-updates/{id}/read");
Options(o => o.WithTags("Release Communications"));
}
public override async Task HandleAsync(CancellationToken ct)
{
Guid id = Route<Guid>("id");
Guid userId = User.GetUserId();
ReleaseUpdateAudienceContext audienceContext =
await ReleaseUpdateVisibility.GetAudienceContextAsync(dbContext, User, userId, ct);
bool canReadUpdate = await dbContext.ReleaseUpdates
.VisibleTo(audienceContext)
.AnyAsync(update => update.Id == id, ct);
if (!canReadUpdate)
{
await SendNotFoundAsync(ct);
return;
}
bool alreadyRead = await dbContext.ReleaseUpdateReadReceipts.AnyAsync(
receipt => receipt.ReleaseUpdateId == id && receipt.UserId == userId,
ct);
if (!alreadyRead)
{
dbContext.ReleaseUpdateReadReceipts.AddRange(
ReleaseUpdateReadState.CreateMissingReadReceipts(
userId,
[id],
new HashSet<Guid>(),
DateTimeOffset.UtcNow));
await dbContext.SaveChangesAsync(ct);
}
await SendNoContentAsync(ct);
}
}

View File

@@ -0,0 +1,45 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Modules.Identity.Contracts;
using Socialize.Api.Modules.ReleaseCommunications.Contracts;
using Socialize.Api.Modules.ReleaseCommunications.Data;
namespace Socialize.Api.Modules.ReleaseCommunications.Handlers;
internal class PublishDeveloperReleaseUpdateHandler(AppDbContext dbContext)
: EndpointWithoutRequest<ReleaseUpdateDto>
{
public override void Configure()
{
Post("/api/developer/release-updates/{id}/publish");
Roles(KnownRoles.Developer);
Options(o => o.WithTags("Release Communications"));
}
public override async Task HandleAsync(CancellationToken ct)
{
Guid id = Route<Guid>("id");
ReleaseUpdate? update = await dbContext.ReleaseUpdates.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
if (update is null)
{
await SendNotFoundAsync(ct);
return;
}
if (update.Status != ReleaseUpdateStatus.Draft)
{
AddError("Only draft release updates can be published.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
DateTimeOffset now = DateTimeOffset.UtcNow;
update.Status = ReleaseUpdateStatus.Published;
update.PublishedAt = now;
update.UpdatedAt = now;
await dbContext.SaveChangesAsync(ct);
await SendOkAsync(update.ToDto(false), ct);
}
}

View File

@@ -0,0 +1,55 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Identity.Contracts;
using Socialize.Api.Modules.ReleaseCommunications.Contracts;
using Socialize.Api.Modules.ReleaseCommunications.Data;
using Socialize.Api.Modules.ReleaseCommunications.Services;
namespace Socialize.Api.Modules.ReleaseCommunications.Handlers;
internal record SendDeveloperReleaseUpdateEmailRequest(
bool TestMode,
bool ConfirmResend);
internal class SendDeveloperReleaseUpdateEmailHandler(
AppDbContext dbContext,
ReleaseUpdateEmailService emailService)
: Endpoint<SendDeveloperReleaseUpdateEmailRequest, ReleaseUpdateEmailSendResultDto>
{
public override void Configure()
{
Post("/api/developer/release-updates/{id}/send-email");
Roles(KnownRoles.Developer);
Options(o => o.WithTags("Release Communications"));
}
public override async Task HandleAsync(SendDeveloperReleaseUpdateEmailRequest request, CancellationToken ct)
{
Guid id = Route<Guid>("id");
ReleaseUpdate? update = await dbContext.ReleaseUpdates.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
if (update is null)
{
await SendNotFoundAsync(ct);
return;
}
try
{
ReleaseUpdateEmailSendResultDto result = await emailService.SendManualUpdateEmailAsync(
update,
User.GetUserId(),
request.TestMode,
request.ConfirmResend,
ct);
await dbContext.SaveChangesAsync(ct);
await SendOkAsync(result, ct);
}
catch (InvalidOperationException ex)
{
AddError(ex.Message);
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
}
}
}

View File

@@ -0,0 +1,143 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Modules.Identity.Contracts;
using Socialize.Api.Modules.ReleaseCommunications.Contracts;
using Socialize.Api.Modules.ReleaseCommunications.Data;
namespace Socialize.Api.Modules.ReleaseCommunications.Handlers;
internal record LinkDeveloperReleaseCommitRequest(Guid ReleaseUpdateId);
internal abstract class ReleaseCommitStatusEndpoint(AppDbContext dbContext)
: EndpointWithoutRequest<ReleaseCommitDto>
{
protected AppDbContext DbContext => dbContext;
protected async Task<ReleaseCommit?> GetCommitAsync(CancellationToken ct)
{
string? sha = Route<string>("sha");
if (string.IsNullOrWhiteSpace(sha))
{
return null;
}
return await DbContext.ReleaseCommits.SingleOrDefaultAsync(commit => commit.Sha == sha, ct);
}
protected async Task SendCommitAsync(ReleaseCommit commit, CancellationToken ct)
{
commit.UpdatedAt = DateTimeOffset.UtcNow;
await DbContext.SaveChangesAsync(ct);
await SendOkAsync(commit.ToDto(), ct);
}
}
internal class LinkDeveloperReleaseCommitHandler(AppDbContext dbContext)
: Endpoint<LinkDeveloperReleaseCommitRequest, ReleaseCommitDto>
{
public override void Configure()
{
Post("/api/developer/release-commits/{sha}/link");
Roles(KnownRoles.Developer);
Options(o => o.WithTags("Release Communications"));
}
public override async Task HandleAsync(LinkDeveloperReleaseCommitRequest request, CancellationToken ct)
{
string? sha = Route<string>("sha");
if (string.IsNullOrWhiteSpace(sha))
{
await SendNotFoundAsync(ct);
return;
}
ReleaseCommit? commit = await dbContext.ReleaseCommits.SingleOrDefaultAsync(candidate => candidate.Sha == sha, ct);
if (commit is null || !await dbContext.ReleaseUpdates.AnyAsync(update => update.Id == request.ReleaseUpdateId, ct))
{
await SendNotFoundAsync(ct);
return;
}
commit.ReleaseUpdateId = request.ReleaseUpdateId;
commit.CommunicationStatus = ReleaseCommitCommunicationStatus.Linked;
commit.UpdatedAt = DateTimeOffset.UtcNow;
await dbContext.SaveChangesAsync(ct);
await SendOkAsync(commit.ToDto(), ct);
}
}
internal class UnlinkDeveloperReleaseCommitHandler(AppDbContext dbContext)
: ReleaseCommitStatusEndpoint(dbContext)
{
public override void Configure()
{
Post("/api/developer/release-commits/{sha}/unlink");
Roles(KnownRoles.Developer);
Options(o => o.WithTags("Release Communications"));
}
public override async Task HandleAsync(CancellationToken ct)
{
ReleaseCommit? commit = await GetCommitAsync(ct);
if (commit is null)
{
await SendNotFoundAsync(ct);
return;
}
commit.ReleaseUpdateId = null;
commit.CommunicationStatus = ReleaseCommitCommunicationStatus.Unreviewed;
await SendCommitAsync(commit, ct);
}
}
internal class MarkDeveloperReleaseCommitInternalOnlyHandler(AppDbContext dbContext)
: ReleaseCommitStatusEndpoint(dbContext)
{
public override void Configure()
{
Post("/api/developer/release-commits/{sha}/internal-only");
Roles(KnownRoles.Developer);
Options(o => o.WithTags("Release Communications"));
}
public override async Task HandleAsync(CancellationToken ct)
{
ReleaseCommit? commit = await GetCommitAsync(ct);
if (commit is null)
{
await SendNotFoundAsync(ct);
return;
}
commit.ReleaseUpdateId = null;
commit.CommunicationStatus = ReleaseCommitCommunicationStatus.InternalOnly;
await SendCommitAsync(commit, ct);
}
}
internal class IgnoreDeveloperReleaseCommitHandler(AppDbContext dbContext)
: ReleaseCommitStatusEndpoint(dbContext)
{
public override void Configure()
{
Post("/api/developer/release-commits/{sha}/ignore");
Roles(KnownRoles.Developer);
Options(o => o.WithTags("Release Communications"));
}
public override async Task HandleAsync(CancellationToken ct)
{
ReleaseCommit? commit = await GetCommitAsync(ct);
if (commit is null)
{
await SendNotFoundAsync(ct);
return;
}
commit.ReleaseUpdateId = null;
commit.CommunicationStatus = ReleaseCommitCommunicationStatus.Ignored;
await SendCommitAsync(commit, ct);
}
}

View File

@@ -0,0 +1,120 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Modules.Identity.Contracts;
using Socialize.Api.Modules.ReleaseCommunications.Contracts;
using Socialize.Api.Modules.ReleaseCommunications.Data;
using Socialize.Api.Modules.ReleaseCommunications.Services;
namespace Socialize.Api.Modules.ReleaseCommunications.Handlers;
internal record UpdateDeveloperReleaseUpdateRequest(
string Title,
string Summary,
string? Body,
string Category,
string Importance,
string Audience,
string? DeploymentLabel,
string? BuildVersion,
string? CommitRange);
internal class UpdateDeveloperReleaseUpdateRequestValidator
: Validator<UpdateDeveloperReleaseUpdateRequest>
{
public UpdateDeveloperReleaseUpdateRequestValidator()
{
RuleFor(x => x.Title).NotEmpty().MaximumLength(160);
RuleFor(x => x.Summary).NotEmpty().MaximumLength(512);
RuleFor(x => x.Body).MaximumLength(8000);
RuleFor(x => x.Category).NotEmpty().MaximumLength(32);
RuleFor(x => x.Importance).NotEmpty().MaximumLength(32);
RuleFor(x => x.Audience).NotEmpty().MaximumLength(32);
RuleFor(x => x.DeploymentLabel).MaximumLength(128);
RuleFor(x => x.BuildVersion).MaximumLength(128);
RuleFor(x => x.CommitRange).MaximumLength(256);
}
}
internal class UpdateDeveloperReleaseUpdateHandler(AppDbContext dbContext)
: Endpoint<UpdateDeveloperReleaseUpdateRequest, ReleaseUpdateDto>
{
public override void Configure()
{
Put("/api/developer/release-updates/{id}");
Roles(KnownRoles.Developer);
Options(o => o.WithTags("Release Communications"));
}
public override async Task HandleAsync(UpdateDeveloperReleaseUpdateRequest request, CancellationToken ct)
{
Guid id = Route<Guid>("id");
ReleaseUpdate? update = await dbContext.ReleaseUpdates.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
if (update is null)
{
await SendNotFoundAsync(ct);
return;
}
if (update.Status != ReleaseUpdateStatus.Draft)
{
AddError("Only draft release updates can be edited.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
if (!TryParseRequest(request, out ReleaseUpdateCategory category, out ReleaseUpdateImportance importance, out ReleaseUpdateAudience audience))
{
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
update.Title = request.Title.Trim();
update.Summary = request.Summary.Trim();
update.Body = NormalizeOptional(request.Body);
update.Category = category;
update.Importance = importance;
update.Audience = audience;
update.DeploymentLabel = NormalizeOptional(request.DeploymentLabel);
update.BuildVersion = NormalizeOptional(request.BuildVersion);
update.CommitRange = NormalizeOptional(request.CommitRange);
update.UpdatedAt = DateTimeOffset.UtcNow;
await dbContext.SaveChangesAsync(ct);
await SendOkAsync(update.ToDto(false), ct);
}
private bool TryParseRequest(
UpdateDeveloperReleaseUpdateRequest request,
out ReleaseUpdateCategory category,
out ReleaseUpdateImportance importance,
out ReleaseUpdateAudience audience)
{
bool isValid = true;
if (!ReleaseUpdateRules.TryParseCategory(request.Category, out category))
{
AddError(x => x.Category, "The selected release update category is not valid.");
isValid = false;
}
if (!ReleaseUpdateRules.TryParseImportance(request.Importance, out importance))
{
AddError(x => x.Importance, "The selected release update importance is not valid.");
isValid = false;
}
if (!ReleaseUpdateRules.TryParseAudience(request.Audience, out audience))
{
AddError(x => x.Audience, "The selected release update audience is not valid.");
isValid = false;
}
return isValid;
}
private static string? NormalizeOptional(string? value)
{
string? normalized = value?.Trim();
return string.IsNullOrWhiteSpace(normalized) ? null : normalized;
}
}

View File

@@ -0,0 +1,19 @@
using Socialize.Api.Modules.ReleaseCommunications.Configuration;
using Socialize.Api.Modules.ReleaseCommunications.Services;
namespace Socialize.Api.Modules.ReleaseCommunications;
internal static class ModuleRegistration
{
public static WebApplicationBuilder AddReleaseCommunicationsModule(this WebApplicationBuilder builder)
{
builder.Services.Configure<ReleaseCommunicationEmailOptions>(
builder.Configuration.GetSection(ReleaseCommunicationEmailOptions.SectionName));
builder.Services.Configure<ReleaseCommunicationRepositoryOptions>(
builder.Configuration.GetSection(ReleaseCommunicationRepositoryOptions.SectionName));
builder.Services.AddScoped<ReleaseUpdateEmailService>();
builder.Services.AddHostedService<ReleaseUpdateEmailDigestBackgroundService>();
return builder;
}
}

View File

@@ -0,0 +1,60 @@
using Microsoft.Extensions.Options;
using Socialize.Api.Modules.ReleaseCommunications.Configuration;
namespace Socialize.Api.Modules.ReleaseCommunications.Services;
internal sealed class ReleaseUpdateEmailDigestBackgroundService(
IServiceScopeFactory scopeFactory,
IOptions<ReleaseCommunicationEmailOptions> options,
ILogger<ReleaseUpdateEmailDigestBackgroundService> logger)
: BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using PeriodicTimer timer = new(TimeSpan.FromHours(1));
while (!stoppingToken.IsCancellationRequested)
{
await SendDueDigestsAsync(stoppingToken);
try
{
await timer.WaitForNextTickAsync(stoppingToken);
}
catch (OperationCanceledException ex) when (stoppingToken.IsCancellationRequested)
{
logger.LogDebug(ex, "Release update digest timer stopped.");
}
}
}
private async Task SendDueDigestsAsync(CancellationToken stoppingToken)
{
if (!options.Value.DigestEnabled)
{
return;
}
try
{
using IServiceScope scope = scopeFactory.CreateScope();
ReleaseUpdateEmailService emailService = scope.ServiceProvider.GetRequiredService<ReleaseUpdateEmailService>();
int sentCount = await emailService.SendDueDigestEmailsAsync(
TimeSpan.FromHours(options.Value.InactiveHoursBeforeDigest),
TimeSpan.FromHours(options.Value.DigestIntervalHours),
stoppingToken);
if (sentCount > 0 && logger.IsEnabled(LogLevel.Information))
{
logger.LogInformation("Sent {SentCount} release update digest emails.", sentCount);
}
}
catch (OperationCanceledException ex) when (stoppingToken.IsCancellationRequested)
{
logger.LogDebug(ex, "Release update digest service stopped.");
}
#pragma warning disable CA1031
catch (Exception ex)
{
logger.LogError(ex, "Release update digest service failed.");
}
#pragma warning restore CA1031
}
}

View File

@@ -0,0 +1,14 @@
namespace Socialize.Api.Modules.ReleaseCommunications.Services;
internal static class ReleaseUpdateEmailRules
{
public static bool IsInactive(DateTimeOffset? lastAuthenticatedAt, DateTimeOffset inactiveBefore)
{
return !lastAuthenticatedAt.HasValue || lastAuthenticatedAt.Value <= inactiveBefore;
}
public static bool CanSendDigest(DateTimeOffset? lastDigestSentAt, DateTimeOffset lastSentBefore)
{
return !lastDigestSentAt.HasValue || lastDigestSentAt.Value <= lastSentBefore;
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,24 @@
using Socialize.Api.Modules.ReleaseCommunications.Data;
namespace Socialize.Api.Modules.ReleaseCommunications.Services;
internal static class ReleaseUpdateReadState
{
public static IReadOnlyCollection<ReleaseUpdateReadReceipt> CreateMissingReadReceipts(
Guid userId,
IEnumerable<Guid> visibleUpdateIds,
ISet<Guid> existingReadUpdateIds,
DateTimeOffset readAt)
{
return visibleUpdateIds
.Where(updateId => !existingReadUpdateIds.Contains(updateId))
.Select(updateId => new ReleaseUpdateReadReceipt
{
Id = Guid.NewGuid(),
ReleaseUpdateId = updateId,
UserId = userId,
ReadAt = readAt,
})
.ToArray();
}
}

View File

@@ -0,0 +1,28 @@
using Socialize.Api.Modules.ReleaseCommunications.Data;
namespace Socialize.Api.Modules.ReleaseCommunications.Services;
internal static class ReleaseUpdateRules
{
public static bool TryParseCategory(string value, out ReleaseUpdateCategory category)
{
return TryParseEnum(value, out category);
}
public static bool TryParseImportance(string value, out ReleaseUpdateImportance importance)
{
return TryParseEnum(value, out importance);
}
public static bool TryParseAudience(string value, out ReleaseUpdateAudience audience)
{
return TryParseEnum(value, out audience);
}
private static bool TryParseEnum<TEnum>(string value, out TEnum result)
where TEnum : struct
{
string normalized = value.Replace(" ", string.Empty, StringComparison.Ordinal);
return Enum.TryParse(normalized, ignoreCase: true, out result);
}
}

View File

@@ -0,0 +1,46 @@
using System.Security.Claims;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Modules.Identity.Contracts;
using Socialize.Api.Modules.Organizations.Data;
using Socialize.Api.Modules.Organizations.Services;
using Socialize.Api.Modules.ReleaseCommunications.Data;
namespace Socialize.Api.Modules.ReleaseCommunications.Services;
internal static class ReleaseUpdateVisibility
{
public static async Task<ReleaseUpdateAudienceContext> GetAudienceContextAsync(
AppDbContext dbContext,
ClaimsPrincipal user,
Guid userId,
CancellationToken ct)
{
bool isDeveloper = user.IsInRole(KnownRoles.Developer);
bool isOrganizationOwner = await dbContext.Organizations.AnyAsync(
organization => organization.OwnerUserId == userId,
ct)
|| await dbContext.OrganizationMemberships.AnyAsync(
membership =>
membership.UserId == userId &&
membership.Role == OrganizationRoles.Owner,
ct);
return new ReleaseUpdateAudienceContext(isDeveloper, isOrganizationOwner);
}
public static IQueryable<ReleaseUpdate> VisibleTo(
this IQueryable<ReleaseUpdate> query,
ReleaseUpdateAudienceContext context)
{
return query.Where(update =>
update.Status == ReleaseUpdateStatus.Published &&
(update.Audience == ReleaseUpdateAudience.Everyone ||
(update.Audience == ReleaseUpdateAudience.OrganizationOwners && context.IsOrganizationOwner) ||
(update.Audience == ReleaseUpdateAudience.Developers && context.IsDeveloper)));
}
}
internal record ReleaseUpdateAudienceContext(
bool IsDeveloper,
bool IsOrganizationOwner);