Simplify release notes workflow
Some checks failed
deploy-socialize / image (push) Successful in 1m9s
deploy-socialize / deploy (push) Has been cancelled

This commit is contained in:
2026-05-08 00:37:14 -04:00
parent 2eb54b9228
commit dcfdce1ec6
47 changed files with 12370 additions and 1974 deletions

View File

@@ -5,23 +5,16 @@ namespace Socialize.Api.Modules.ReleaseCommunications.Contracts;
internal record ReleaseUpdateDto(
Guid Id,
string Title,
string Summary,
string? Body,
string Category,
string Importance,
string Audience,
string Description,
string TitleEn,
string DescriptionEn,
string TitleFr,
string DescriptionFr,
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(
@@ -40,16 +33,13 @@ internal record ReleaseCommitDto(
DateTimeOffset ImportedAt,
DateTimeOffset UpdatedAt);
internal record ReleaseCommitImportResultDto(
int ImportedCount,
internal record ReleaseCommitRefreshResultDto(
int CreatedCount,
int UpdatedCount,
int SkippedCount,
IReadOnlyCollection<ReleaseCommitDto> Commits);
internal record ReleaseUpdateEmailSendResultDto(
int RecipientCount,
DateTimeOffset SentAt,
bool TestMode);
internal record ReleaseCommitBulkLinkResultDto(int LinkedCount);
internal record ReleaseUpdateUnreadSummaryDto(
int UnreadCount,
@@ -64,22 +54,15 @@ internal static class ReleaseUpdateDtoMapper
update.Id,
update.Title,
update.Summary,
update.Body,
ToDisplayString(update.Category),
update.Importance.ToString(),
update.Audience.ToString(),
update.Title,
update.Summary,
update.TitleFr,
update.SummaryFr,
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);
}
@@ -102,8 +85,4 @@ internal static class ReleaseUpdateDtoMapper
commit.UpdatedAt);
}
private static string ToDisplayString(ReleaseUpdateCategory category)
{
return category == ReleaseUpdateCategory.BreakingChange ? "Breaking Change" : category.ToString();
}
}

View File

@@ -11,19 +11,12 @@ internal static class ReleaseCommunicationsModelConfiguration
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.Summary).HasMaxLength(4000).IsRequired();
releaseUpdate.Property(x => x.TitleFr).HasMaxLength(160).IsRequired();
releaseUpdate.Property(x => x.SummaryFr).HasMaxLength(4000).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);
});

View File

@@ -5,22 +5,13 @@ 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 string TitleFr { get; set; } = string.Empty;
public string SummaryFr { get; set; } = string.Empty;
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

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

View File

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

View File

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

View File

@@ -4,35 +4,24 @@ 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);
string TitleEn,
string DescriptionEn,
string TitleFr,
string DescriptionFr);
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);
RuleFor(x => x.TitleEn).NotEmpty().MaximumLength(160);
RuleFor(x => x.DescriptionEn).NotEmpty().MaximumLength(4000);
RuleFor(x => x.TitleFr).NotEmpty().MaximumLength(160);
RuleFor(x => x.DescriptionFr).NotEmpty().MaximumLength(4000);
}
}
@@ -48,26 +37,15 @@ internal class CreateDeveloperReleaseUpdateHandler(AppDbContext dbContext)
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,
Title = request.TitleEn.Trim(),
Summary = request.DescriptionEn.Trim(),
TitleFr = request.TitleFr.Trim(),
SummaryFr = request.DescriptionFr.Trim(),
Status = ReleaseUpdateStatus.Draft,
DeploymentLabel = NormalizeOptional(request.DeploymentLabel),
BuildVersion = NormalizeOptional(request.BuildVersion),
CommitRange = NormalizeOptional(request.CommitRange),
CreatedByUserId = User.GetUserId(),
CreatedAt = now,
UpdatedAt = now,
@@ -78,38 +56,4 @@ internal class CreateDeveloperReleaseUpdateHandler(AppDbContext dbContext)
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

@@ -20,11 +20,9 @@ internal class GetUnreadReleaseUpdatesHandler(AppDbContext dbContext)
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)
.VisibleToUsers()
.Where(update => !dbContext.ReleaseUpdateReadReceipts.Any(receipt =>
receipt.ReleaseUpdateId == update.Id &&
receipt.UserId == userId))
@@ -35,7 +33,7 @@ internal class GetUnreadReleaseUpdatesHandler(AppDbContext dbContext)
await SendOkAsync(
new ReleaseUpdateUnreadSummaryDto(
unreadUpdates.Count,
unreadUpdates.Count(update => update.Importance == ReleaseUpdateImportance.Important),
0,
unreadUpdates.Select(update => update.ToDto(false)).ToArray()),
ct);
}

View File

@@ -1,156 +0,0 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
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 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,
DateTimeOffset? Since,
DateTimeOffset? Until,
int? Limit,
IReadOnlyCollection<ImportDeveloperReleaseCommitDto>? Commits);
internal class ImportDeveloperReleaseCommitsHandler(
AppDbContext dbContext,
ReleaseCommitRepositoryImportService repositoryImportService)
: 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)
{
IReadOnlyCollection<ReleaseCommit> requestedCommits;
if (request.Commits is { Count: > 0 })
{
requestedCommits = request.Commits.Select(ToReleaseCommit).ToArray();
}
else
{
try
{
ReleaseCommitRepositoryImportResult importResult = await repositoryImportService.FetchCommitsAsync(request, ct);
if (!importResult.IsSuccess)
{
AddError(importResult.ErrorMessage ?? "Repository commit import failed.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
requestedCommits = importResult.Commits;
}
catch (HttpRequestException ex)
{
AddError(ex.Message);
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
catch (JsonException ex)
{
AddError(ex.Message);
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
}
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 = ToUtc(dto.AuthoredAt),
CommittedAt = ToUtc(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;
}
private static DateTimeOffset? ToUtc(DateTimeOffset? value)
{
return value?.ToUniversalTime();
}
}

View File

@@ -20,11 +20,9 @@ internal class ListReleaseUpdatesHandler(AppDbContext dbContext)
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)
.VisibleToUsers()
.OrderByDescending(update => update.PublishedAt)
.ThenByDescending(update => update.CreatedAt)
.ToListAsync(ct);

View File

@@ -19,11 +19,9 @@ internal class MarkAllReleaseUpdatesReadHandler(AppDbContext dbContext)
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)
.VisibleToUsers()
.Select(update => update.Id)
.ToListAsync(ct);

View File

@@ -20,11 +20,9 @@ internal class MarkReleaseUpdateReadHandler(AppDbContext dbContext)
{
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)
.VisibleToUsers()
.AnyAsync(update => update.Id == id, ct);
if (!canReadUpdate)

View File

@@ -0,0 +1,95 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
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 class RefreshDeveloperReleaseCommitsHandler(
AppDbContext dbContext,
ReleaseCommitRepositoryRefreshService repositoryRefreshService)
: EndpointWithoutRequest<ReleaseCommitRefreshResultDto>
{
public override void Configure()
{
Post("/api/developer/release-commits/refresh");
Roles(KnownRoles.Developer);
Options(o => o.WithTags("Release Communications"));
}
public override async Task HandleAsync(CancellationToken ct)
{
IReadOnlyCollection<ReleaseCommit> requestedCommits;
try
{
ReleaseCommitRepositoryRefreshResult refreshResult = await repositoryRefreshService.FetchCommitsAsync(ct);
if (!refreshResult.IsSuccess)
{
AddError(refreshResult.ErrorMessage ?? "Repository commit refresh failed.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
requestedCommits = refreshResult.Commits;
}
catch (HttpRequestException ex)
{
AddError(ex.Message);
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
catch (JsonException ex)
{
AddError(ex.Message);
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
int created = 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);
created++;
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 ReleaseCommitRefreshResultDto(created, updated, skipped, savedCommits.Select(commit => commit.ToDto()).ToArray()),
ct);
}
}

View File

@@ -1,55 +0,0 @@
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

@@ -9,6 +9,8 @@ namespace Socialize.Api.Modules.ReleaseCommunications.Handlers;
internal record LinkDeveloperReleaseCommitRequest(Guid ReleaseUpdateId);
internal record LinkFirstReleaseCommitsRequest(Guid ReleaseUpdateId);
internal abstract class ReleaseCommitStatusEndpoint(AppDbContext dbContext)
: EndpointWithoutRequest<ReleaseCommitDto>
{
@@ -67,6 +69,70 @@ internal class LinkDeveloperReleaseCommitHandler(AppDbContext dbContext)
}
}
internal class LinkFirstReleaseCommitsHandler(AppDbContext dbContext)
: Endpoint<LinkFirstReleaseCommitsRequest, ReleaseCommitBulkLinkResultDto>
{
public override void Configure()
{
Post("/api/developer/release-commits/{sha}/link-first-release");
Roles(KnownRoles.Developer);
Options(o => o.WithTags("Release Communications"));
}
public override async Task HandleAsync(LinkFirstReleaseCommitsRequest request, CancellationToken ct)
{
string? sha = Route<string>("sha");
if (string.IsNullOrWhiteSpace(sha))
{
await SendNotFoundAsync(ct);
return;
}
bool releaseUpdateExists = await dbContext.ReleaseUpdates
.AnyAsync(update => update.Id == request.ReleaseUpdateId, ct);
ReleaseCommit? anchorCommit = await dbContext.ReleaseCommits
.SingleOrDefaultAsync(commit => commit.Sha == sha, ct);
if (!releaseUpdateExists || anchorCommit is null)
{
await SendNotFoundAsync(ct);
return;
}
if (anchorCommit.ReleaseUpdateId is not null ||
anchorCommit.CommunicationStatus != ReleaseCommitCommunicationStatus.Unreviewed)
{
AddError("The selected first release commit must be unlinked and unreviewed.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
DateTimeOffset anchorDate = CommitDate(anchorCommit);
List<ReleaseCommit> commits = await dbContext.ReleaseCommits
.Where(commit =>
commit.ReleaseUpdateId == null &&
commit.CommunicationStatus == ReleaseCommitCommunicationStatus.Unreviewed &&
(commit.CommittedAt ?? commit.AuthoredAt ?? commit.ImportedAt) <= anchorDate)
.ToListAsync(ct);
DateTimeOffset now = DateTimeOffset.UtcNow;
foreach (ReleaseCommit commit in commits)
{
commit.ReleaseUpdateId = request.ReleaseUpdateId;
commit.CommunicationStatus = ReleaseCommitCommunicationStatus.Linked;
commit.UpdatedAt = now;
}
await dbContext.SaveChangesAsync(ct);
await SendOkAsync(new ReleaseCommitBulkLinkResultDto(commits.Count), ct);
}
private static DateTimeOffset CommitDate(ReleaseCommit commit)
{
return commit.CommittedAt ?? commit.AuthoredAt ?? commit.ImportedAt;
}
}
internal class UnlinkDeveloperReleaseCommitHandler(AppDbContext dbContext)
: ReleaseCommitStatusEndpoint(dbContext)
{

View File

@@ -4,35 +4,24 @@ 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);
string TitleEn,
string DescriptionEn,
string TitleFr,
string DescriptionFr);
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);
RuleFor(x => x.TitleEn).NotEmpty().MaximumLength(160);
RuleFor(x => x.DescriptionEn).NotEmpty().MaximumLength(4000);
RuleFor(x => x.TitleFr).NotEmpty().MaximumLength(160);
RuleFor(x => x.DescriptionFr).NotEmpty().MaximumLength(4000);
}
}
@@ -63,58 +52,13 @@ internal class UpdateDeveloperReleaseUpdateHandler(AppDbContext dbContext)
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.Title = request.TitleEn.Trim();
update.Summary = request.DescriptionEn.Trim();
update.TitleFr = request.TitleFr.Trim();
update.SummaryFr = request.DescriptionFr.Trim();
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

@@ -12,7 +12,7 @@ internal static class ModuleRegistration
builder.Services.Configure<ReleaseCommunicationRepositoryOptions>(
builder.Configuration.GetSection(ReleaseCommunicationRepositoryOptions.SectionName));
builder.Services.AddScoped<ReleaseUpdateEmailService>();
builder.Services.AddScoped<ReleaseCommitRepositoryImportService>();
builder.Services.AddScoped<ReleaseCommitRepositoryRefreshService>();
builder.Services.AddHostedService<ReleaseUpdateEmailDigestBackgroundService>();
return builder;

View File

@@ -5,46 +5,41 @@ using System.Text.Json;
using Microsoft.Extensions.Options;
using Socialize.Api.Modules.ReleaseCommunications.Configuration;
using Socialize.Api.Modules.ReleaseCommunications.Data;
using Socialize.Api.Modules.ReleaseCommunications.Handlers;
namespace Socialize.Api.Modules.ReleaseCommunications.Services;
internal sealed record ReleaseCommitRepositoryImportResult(
internal sealed record ReleaseCommitRepositoryRefreshResult(
IReadOnlyCollection<ReleaseCommit> Commits,
string? ErrorMessage)
{
public bool IsSuccess => ErrorMessage is null;
public static ReleaseCommitRepositoryImportResult Success(IReadOnlyCollection<ReleaseCommit> commits)
public static ReleaseCommitRepositoryRefreshResult Success(IReadOnlyCollection<ReleaseCommit> commits)
{
return new ReleaseCommitRepositoryImportResult(commits, null);
return new ReleaseCommitRepositoryRefreshResult(commits, null);
}
public static ReleaseCommitRepositoryImportResult Failure(string errorMessage)
public static ReleaseCommitRepositoryRefreshResult Failure(string errorMessage)
{
return new ReleaseCommitRepositoryImportResult([], errorMessage);
return new ReleaseCommitRepositoryRefreshResult([], errorMessage);
}
}
internal sealed class ReleaseCommitRepositoryImportService(
internal sealed class ReleaseCommitRepositoryRefreshService(
IHttpClientFactory httpClientFactory,
IOptionsSnapshot<ReleaseCommunicationRepositoryOptions> repositoryOptions)
{
private const int DefaultLimit = 50;
private const int MaxLimit = 100;
public async Task<ReleaseCommitRepositoryImportResult> FetchCommitsAsync(
ImportDeveloperReleaseCommitsRequest request,
public async Task<ReleaseCommitRepositoryRefreshResult> FetchCommitsAsync(
CancellationToken ct)
{
ReleaseCommunicationRepositoryOptions options = repositoryOptions.Value;
if (!TryBuildApiTarget(options.RepositoryUrl, out RepositoryApiTarget target, out string? targetError))
{
return ReleaseCommitRepositoryImportResult.Failure(targetError ?? "Repository configuration is not valid.");
return ReleaseCommitRepositoryRefreshResult.Failure(targetError ?? "Repository configuration is not valid.");
}
int limit = Math.Clamp(request.Limit ?? DefaultLimit, 1, MaxLimit);
using HttpClient httpClient = httpClientFactory.CreateClient();
httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("Socialize", "1.0"));
if (!string.IsNullOrWhiteSpace(options.AccessToken))
@@ -52,11 +47,11 @@ internal sealed class ReleaseCommitRepositoryImportService(
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("token", options.AccessToken.Trim());
}
using HttpResponseMessage response = await httpClient.GetAsync(BuildRequestUri(target, request, limit), ct);
using HttpResponseMessage response = await httpClient.GetAsync(BuildRequestUri(target), ct);
if (!response.IsSuccessStatusCode)
{
return ReleaseCommitRepositoryImportResult.Failure(
$"Repository commit import failed with HTTP {(int)response.StatusCode} ({response.ReasonPhrase}).");
return ReleaseCommitRepositoryRefreshResult.Failure(
$"Repository commit refresh failed with HTTP {(int)response.StatusCode} ({response.ReasonPhrase}).");
}
await using Stream stream = await response.Content.ReadAsStreamAsync(ct);
@@ -73,56 +68,31 @@ internal sealed class ReleaseCommitRepositoryImportService(
}
else
{
return ReleaseCommitRepositoryImportResult.Failure("Repository API response did not include a commit list.");
return ReleaseCommitRepositoryRefreshResult.Failure("Repository API response did not include a commit list.");
}
DateTimeOffset now = DateTimeOffset.UtcNow;
List<ReleaseCommit> commits = [];
foreach (JsonElement commitElement in commitsElement.EnumerateArray())
{
ReleaseCommit? commit = ToReleaseCommit(commitElement, request, now);
ReleaseCommit? commit = ToReleaseCommit(commitElement, now);
if (commit is not null)
{
commits.Add(commit);
}
}
return ReleaseCommitRepositoryImportResult.Success(commits);
return ReleaseCommitRepositoryRefreshResult.Success(commits);
}
private static Uri BuildRequestUri(
RepositoryApiTarget target,
ImportDeveloperReleaseCommitsRequest request,
int limit)
private static Uri BuildRequestUri(RepositoryApiTarget target)
{
if (!string.IsNullOrWhiteSpace(request.SinceSha) && !string.IsNullOrWhiteSpace(request.UntilSha))
{
string baseHead = $"{request.SinceSha.Trim()}...{request.UntilSha.Trim()}";
return new Uri($"{target.ApiBaseUri}/compare/{Uri.EscapeDataString(baseHead)}");
}
Dictionary<string, string> query = new(StringComparer.Ordinal)
{
["limit"] = limit.ToString(CultureInfo.InvariantCulture),
["limit"] = DefaultLimit.ToString(CultureInfo.InvariantCulture),
["page"] = "1",
};
string? sha = NormalizeOptional(request.UntilSha) ?? NormalizeOptional(request.SourceBranch);
if (sha is not null)
{
query["sha"] = sha;
}
if (request.Since.HasValue)
{
query["since"] = request.Since.Value.UtcDateTime.ToString("O", CultureInfo.InvariantCulture);
}
if (request.Until.HasValue)
{
query["until"] = request.Until.Value.UtcDateTime.ToString("O", CultureInfo.InvariantCulture);
}
string queryString = string.Join(
"&",
query.Select(pair => $"{WebUtility.UrlEncode(pair.Key)}={WebUtility.UrlEncode(pair.Value)}"));
@@ -140,7 +110,7 @@ internal sealed class ReleaseCommitRepositoryImportService(
if (string.IsNullOrWhiteSpace(repositoryUrl))
{
errorMessage = "ReleaseCommunications:Repository:RepositoryUrl is required before repository import can be used.";
errorMessage = "ReleaseCommunications:Repository:RepositoryUrl is required before repository refresh can be used.";
return false;
}
@@ -176,7 +146,6 @@ internal sealed class ReleaseCommitRepositoryImportService(
private static ReleaseCommit? ToReleaseCommit(
JsonElement commitElement,
ImportDeveloperReleaseCommitsRequest request,
DateTimeOffset now)
{
string? sha = GetString(commitElement, "sha") ?? GetString(commitElement, "id");
@@ -211,8 +180,8 @@ internal sealed class ReleaseCommitRepositoryImportService(
AuthorEmail = authorElement.HasValue ? NormalizeOptional(GetString(authorElement.Value, "email")) : null,
AuthoredAt = authorElement.HasValue ? GetUtcDateTimeOffset(authorElement.Value, "date") : null,
CommittedAt = committerElement.HasValue ? GetUtcDateTimeOffset(committerElement.Value, "date") : null,
SourceBranch = NormalizeOptional(request.SourceBranch),
DeploymentLabel = NormalizeOptional(request.DeploymentLabel),
SourceBranch = null,
DeploymentLabel = null,
ExternalUrl = NormalizeOptional(GetString(commitElement, "html_url") ?? GetString(commitElement, "url")),
CommunicationStatus = ReleaseCommitCommunicationStatus.Unreviewed,
ImportedAt = now,

View File

@@ -1,15 +1,11 @@
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;
@@ -20,48 +16,6 @@ internal class ReleaseUpdateEmailService(
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,
@@ -71,7 +25,7 @@ internal class ReleaseUpdateEmailService(
DateTimeOffset inactiveBefore = now.Subtract(inactiveThreshold);
DateTimeOffset lastSentBefore = now.Subtract(sendInterval);
List<User> ownerUsers = await GetAudienceRecipientsAsync(ReleaseUpdateAudience.OrganizationOwners, ct);
List<User> ownerUsers = await GetReleaseNoteRecipientsAsync(ct);
int sentCount = 0;
foreach (User user in ownerUsers)
{
@@ -91,14 +45,8 @@ internal class ReleaseUpdateEmailService(
continue;
}
ReleaseUpdateAudienceContext audienceContext = await ReleaseUpdateVisibility.GetAudienceContextAsync(
dbContext,
new ClaimsPrincipal(new ClaimsIdentity()),
user.Id,
ct);
List<ReleaseUpdate> unreadUpdates = await dbContext.ReleaseUpdates
.VisibleTo(audienceContext)
.VisibleToUsers()
.Where(update => !dbContext.ReleaseUpdateReadReceipts.Any(receipt =>
receipt.ReleaseUpdateId == update.Id &&
receipt.UserId == user.Id))
@@ -130,48 +78,12 @@ internal class ReleaseUpdateEmailService(
return sentCount;
}
private async Task<IReadOnlyCollection<User>> GetTestRecipientsAsync(Guid senderUserId, CancellationToken ct)
private async Task<List<User>> GetReleaseNoteRecipientsAsync(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>
""";
return await userManager.Users
.Where(user => user.EmailConfirmed && user.Email != null)
.OrderBy(user => user.Email)
.ToListAsync(ct);
}
private string BuildDigestEmail(IReadOnlyCollection<ReleaseUpdate> updates)
@@ -179,7 +91,12 @@ internal class ReleaseUpdateEmailService(
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>"));
updates.Select(update => $"""
<li>
<strong>{HtmlEncode(update.Title)}</strong><br>{HtmlEncode(update.Summary)}<br>
<strong>{HtmlEncode(update.TitleFr)}</strong><br>{HtmlEncode(update.SummaryFr)}
</li>
"""));
return $"""
<h1>What's new in Socialize</h1>
@@ -188,13 +105,6 @@ internal class ReleaseUpdateEmailService(
""";
}
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

@@ -1,28 +0,0 @@
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

@@ -1,46 +1,11 @@
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)
public static IQueryable<ReleaseUpdate> VisibleToUsers(this IQueryable<ReleaseUpdate> query)
{
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)));
return query.Where(update => update.Status == ReleaseUpdateStatus.Published);
}
}
internal record ReleaseUpdateAudienceContext(
bool IsDeveloper,
bool IsOrganizationOwner);