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

@@ -12,6 +12,7 @@ using Socialize.Api.Modules.Notifications.Data;
using Socialize.Api.Modules.Campaigns.Data; using Socialize.Api.Modules.Campaigns.Data;
using Socialize.Api.Modules.CalendarIntegrations.Data; using Socialize.Api.Modules.CalendarIntegrations.Data;
using Socialize.Api.Modules.Organizations.Data; using Socialize.Api.Modules.Organizations.Data;
using Socialize.Api.Modules.ReleaseCommunications.Data;
using Socialize.Api.Modules.Workspaces.Data; using Socialize.Api.Modules.Workspaces.Data;
namespace Socialize.Api.Data; namespace Socialize.Api.Data;
@@ -50,6 +51,10 @@ internal class AppDbContext(
public DbSet<CalendarCatalogEntry> CalendarCatalogEntries => Set<CalendarCatalogEntry>(); public DbSet<CalendarCatalogEntry> CalendarCatalogEntries => Set<CalendarCatalogEntry>();
public DbSet<CalendarEvent> CalendarEvents => Set<CalendarEvent>(); public DbSet<CalendarEvent> CalendarEvents => Set<CalendarEvent>();
public DbSet<UserCalendarExportFeed> UserCalendarExportFeeds => Set<UserCalendarExportFeed>(); public DbSet<UserCalendarExportFeed> UserCalendarExportFeeds => Set<UserCalendarExportFeed>();
public DbSet<ReleaseUpdate> ReleaseUpdates => Set<ReleaseUpdate>();
public DbSet<ReleaseUpdateReadReceipt> ReleaseUpdateReadReceipts => Set<ReleaseUpdateReadReceipt>();
public DbSet<ReleaseCommit> ReleaseCommits => Set<ReleaseCommit>();
public DbSet<ReleaseUpdateEmailDigestReceipt> ReleaseUpdateEmailDigestReceipts => Set<ReleaseUpdateEmailDigestReceipt>();
protected override void OnModelCreating(ModelBuilder builder) protected override void OnModelCreating(ModelBuilder builder)
{ {
@@ -67,5 +72,6 @@ internal class AppDbContext(
builder.ConfigureNotificationsModule(); builder.ConfigureNotificationsModule();
builder.ConfigureFeedbackModule(); builder.ConfigureFeedbackModule();
builder.ConfigureCalendarIntegrationsModule(); builder.ConfigureCalendarIntegrationsModule();
builder.ConfigureReleaseCommunicationsModule();
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,197 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Socialize.Api.Migrations
{
/// <inheritdoc />
internal partial class AddReleaseCommunications : Migration
{
private static readonly string[] ReleaseUpdateReadReceiptUniqueIndexColumns =
[
"ReleaseUpdateId",
"UserId",
];
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTimeOffset>(
name: "LastAuthenticatedAt",
table: "AspNetUsers",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.CreateTable(
name: "ReleaseUpdateEmailDigestReceipts",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
SentAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
UpdateCount = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ReleaseUpdateEmailDigestReceipts", x => x.Id);
});
migrationBuilder.CreateTable(
name: "ReleaseUpdates",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Title = table.Column<string>(type: "character varying(160)", maxLength: 160, nullable: false),
Summary = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false),
Body = table.Column<string>(type: "character varying(8000)", maxLength: 8000, nullable: true),
Category = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
Importance = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
Audience = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
Status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
DeploymentLabel = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
BuildVersion = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
CommitRange = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
CreatedByUserId = table.Column<Guid>(type: "uuid", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
PublishedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
ArchivedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
ManualEmailSentByUserId = table.Column<Guid>(type: "uuid", nullable: true),
ManualEmailSentAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
ManualEmailAudience = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
ManualEmailRecipientCount = table.Column<int>(type: "integer", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ReleaseUpdates", x => x.Id);
});
migrationBuilder.CreateTable(
name: "ReleaseCommits",
columns: table => new
{
Sha = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
ShortSha = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
Subject = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false),
AuthorName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
AuthorEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
AuthoredAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
CommittedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
SourceBranch = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
DeploymentLabel = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
ExternalUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
CommunicationStatus = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
ReleaseUpdateId = table.Column<Guid>(type: "uuid", nullable: true),
ImportedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ReleaseCommits", x => x.Sha);
table.ForeignKey(
name: "FK_ReleaseCommits_ReleaseUpdates_ReleaseUpdateId",
column: x => x.ReleaseUpdateId,
principalTable: "ReleaseUpdates",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateTable(
name: "ReleaseUpdateReadReceipts",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ReleaseUpdateId = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
ReadAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
},
constraints: table =>
{
table.PrimaryKey("PK_ReleaseUpdateReadReceipts", x => x.Id);
table.ForeignKey(
name: "FK_ReleaseUpdateReadReceipts_ReleaseUpdates_ReleaseUpdateId",
column: x => x.ReleaseUpdateId,
principalTable: "ReleaseUpdates",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_ReleaseCommits_CommittedAt",
table: "ReleaseCommits",
column: "CommittedAt");
migrationBuilder.CreateIndex(
name: "IX_ReleaseCommits_CommunicationStatus",
table: "ReleaseCommits",
column: "CommunicationStatus");
migrationBuilder.CreateIndex(
name: "IX_ReleaseCommits_ReleaseUpdateId",
table: "ReleaseCommits",
column: "ReleaseUpdateId");
migrationBuilder.CreateIndex(
name: "IX_ReleaseUpdateEmailDigestReceipts_SentAt",
table: "ReleaseUpdateEmailDigestReceipts",
column: "SentAt");
migrationBuilder.CreateIndex(
name: "IX_ReleaseUpdateEmailDigestReceipts_UserId",
table: "ReleaseUpdateEmailDigestReceipts",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_ReleaseUpdateReadReceipts_ReleaseUpdateId_UserId",
table: "ReleaseUpdateReadReceipts",
columns: ReleaseUpdateReadReceiptUniqueIndexColumns,
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ReleaseUpdateReadReceipts_UserId",
table: "ReleaseUpdateReadReceipts",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_ReleaseUpdates_Audience",
table: "ReleaseUpdates",
column: "Audience");
migrationBuilder.CreateIndex(
name: "IX_ReleaseUpdates_CreatedByUserId",
table: "ReleaseUpdates",
column: "CreatedByUserId");
migrationBuilder.CreateIndex(
name: "IX_ReleaseUpdates_PublishedAt",
table: "ReleaseUpdates",
column: "PublishedAt");
migrationBuilder.CreateIndex(
name: "IX_ReleaseUpdates_Status",
table: "ReleaseUpdates",
column: "Status");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ReleaseCommits");
migrationBuilder.DropTable(
name: "ReleaseUpdateEmailDigestReceipts");
migrationBuilder.DropTable(
name: "ReleaseUpdateReadReceipts");
migrationBuilder.DropTable(
name: "ReleaseUpdates");
migrationBuilder.DropColumn(
name: "LastAuthenticatedAt",
table: "AspNetUsers");
}
}
}

View File

@@ -1522,6 +1522,9 @@ namespace Socialize.Api.Migrations
.HasMaxLength(256) .HasMaxLength(256)
.HasColumnType("character varying(256)"); .HasColumnType("character varying(256)");
b.Property<DateTimeOffset?>("LastAuthenticatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Lastname") b.Property<string>("Lastname")
.HasMaxLength(256) .HasMaxLength(256)
.HasColumnType("character varying(256)"); .HasColumnType("character varying(256)");
@@ -1899,6 +1902,223 @@ namespace Socialize.Api.Migrations
}); });
}); });
modelBuilder.Entity("Socialize.Api.Modules.ReleaseCommunications.Data.ReleaseCommit", b =>
{
b.Property<string>("Sha")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("AuthorEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("AuthorName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<DateTimeOffset?>("AuthoredAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("CommittedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CommunicationStatus")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("DeploymentLabel")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("ExternalUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<DateTimeOffset>("ImportedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid?>("ReleaseUpdateId")
.HasColumnType("uuid");
b.Property<string>("ShortSha")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<string>("SourceBranch")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Subject")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Sha");
b.HasIndex("CommittedAt");
b.HasIndex("CommunicationStatus");
b.HasIndex("ReleaseUpdateId");
b.ToTable("ReleaseCommits", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.ReleaseCommunications.Data.ReleaseUpdate", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("ArchivedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Audience")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("Body")
.HasMaxLength(8000)
.HasColumnType("character varying(8000)");
b.Property<string>("BuildVersion")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("CommitRange")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatedByUserId")
.HasColumnType("uuid");
b.Property<string>("DeploymentLabel")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Importance")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("ManualEmailAudience")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int?>("ManualEmailRecipientCount")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("ManualEmailSentAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("ManualEmailSentByUserId")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("PublishedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("Summary")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("Audience");
b.HasIndex("CreatedByUserId");
b.HasIndex("PublishedAt");
b.HasIndex("Status");
b.ToTable("ReleaseUpdates", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.ReleaseCommunications.Data.ReleaseUpdateEmailDigestReceipt", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("SentAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<int>("UpdateCount")
.HasColumnType("integer");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("SentAt");
b.HasIndex("UserId");
b.ToTable("ReleaseUpdateEmailDigestReceipts", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.ReleaseCommunications.Data.ReleaseUpdateReadReceipt", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("ReadAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("ReleaseUpdateId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId");
b.HasIndex("ReleaseUpdateId", "UserId")
.IsUnique();
b.ToTable("ReleaseUpdateReadReceipts", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.Workspaces.Data.Workspace", b => modelBuilder.Entity("Socialize.Api.Modules.Workspaces.Data.Workspace", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -2345,6 +2565,27 @@ namespace Socialize.Api.Migrations
.IsRequired(); .IsRequired();
}); });
modelBuilder.Entity("Socialize.Api.Modules.ReleaseCommunications.Data.ReleaseCommit", b =>
{
b.HasOne("Socialize.Api.Modules.ReleaseCommunications.Data.ReleaseUpdate", "ReleaseUpdate")
.WithMany()
.HasForeignKey("ReleaseUpdateId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("ReleaseUpdate");
});
modelBuilder.Entity("Socialize.Api.Modules.ReleaseCommunications.Data.ReleaseUpdateReadReceipt", b =>
{
b.HasOne("Socialize.Api.Modules.ReleaseCommunications.Data.ReleaseUpdate", "ReleaseUpdate")
.WithMany("ReadReceipts")
.HasForeignKey("ReleaseUpdateId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ReleaseUpdate");
});
modelBuilder.Entity("Socialize.Api.Modules.Workspaces.Data.Workspace", b => modelBuilder.Entity("Socialize.Api.Modules.Workspaces.Data.Workspace", b =>
{ {
b.HasOne("Socialize.Api.Modules.Organizations.Data.Organization", null) b.HasOne("Socialize.Api.Modules.Organizations.Data.Organization", null)
@@ -2373,6 +2614,11 @@ namespace Socialize.Api.Migrations
b.Navigation("Tags"); b.Navigation("Tags");
}); });
modelBuilder.Entity("Socialize.Api.Modules.ReleaseCommunications.Data.ReleaseUpdate", b =>
{
b.Navigation("ReadReceipts");
});
#pragma warning restore 612, 618 #pragma warning restore 612, 618
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,6 +19,7 @@ using Socialize.Api.Modules.Notifications;
using Socialize.Api.Modules.Campaigns; using Socialize.Api.Modules.Campaigns;
using Socialize.Api.Modules.CalendarIntegrations; using Socialize.Api.Modules.CalendarIntegrations;
using Socialize.Api.Modules.Organizations; using Socialize.Api.Modules.Organizations;
using Socialize.Api.Modules.ReleaseCommunications;
using Socialize.Api.Modules.Workspaces; using Socialize.Api.Modules.Workspaces;
@@ -78,6 +79,7 @@ builder.AddApprovalsModule();
builder.AddNotificationsModule(); builder.AddNotificationsModule();
builder.AddFeedbackModule(); builder.AddFeedbackModule();
builder.AddCalendarIntegrationsModule(); builder.AddCalendarIntegrationsModule();
builder.AddReleaseCommunicationsModule();
var app = builder.Build(); var app = builder.Build();

View File

@@ -0,0 +1,192 @@
using Socialize.Api.Modules.ReleaseCommunications.Contracts;
using Socialize.Api.Modules.ReleaseCommunications.Data;
using Socialize.Api.Modules.ReleaseCommunications.Services;
namespace Socialize.Tests.ReleaseCommunications;
public class ReleaseUpdateRulesTests
{
[Theory]
[InlineData("Feature", ReleaseUpdateCategory.Feature)]
[InlineData("improvement", ReleaseUpdateCategory.Improvement)]
[InlineData("Breaking Change", ReleaseUpdateCategory.BreakingChange)]
[InlineData("BreakingChange", ReleaseUpdateCategory.BreakingChange)]
internal void TryParseCategory_accepts_supported_categories(string value, ReleaseUpdateCategory expected)
{
bool parsed = ReleaseUpdateRules.TryParseCategory(value, out ReleaseUpdateCategory category);
Assert.True(parsed);
Assert.Equal(expected, category);
}
[Theory]
[InlineData("")]
[InlineData("Security")]
[InlineData("Maintenance")]
public void TryParseCategory_rejects_unsupported_categories(string value)
{
bool parsed = ReleaseUpdateRules.TryParseCategory(value, out _);
Assert.False(parsed);
}
[Theory]
[InlineData("Normal", ReleaseUpdateImportance.Normal)]
[InlineData("important", ReleaseUpdateImportance.Important)]
internal void TryParseImportance_accepts_supported_importance(string value, ReleaseUpdateImportance expected)
{
bool parsed = ReleaseUpdateRules.TryParseImportance(value, out ReleaseUpdateImportance importance);
Assert.True(parsed);
Assert.Equal(expected, importance);
}
[Theory]
[InlineData("Everyone", ReleaseUpdateAudience.Everyone)]
[InlineData("Organization Owners", ReleaseUpdateAudience.OrganizationOwners)]
[InlineData("developers", ReleaseUpdateAudience.Developers)]
internal void TryParseAudience_accepts_supported_audiences(string value, ReleaseUpdateAudience expected)
{
bool parsed = ReleaseUpdateRules.TryParseAudience(value, out ReleaseUpdateAudience audience);
Assert.True(parsed);
Assert.Equal(expected, audience);
}
[Fact]
public void ToDto_formats_breaking_change_category_for_display()
{
ReleaseUpdate update = new()
{
Id = Guid.NewGuid(),
Title = "API change",
Summary = "A workflow API changed.",
Category = ReleaseUpdateCategory.BreakingChange,
Importance = ReleaseUpdateImportance.Important,
Audience = ReleaseUpdateAudience.Developers,
Status = ReleaseUpdateStatus.Published,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
PublishedAt = DateTimeOffset.UtcNow,
CreatedByUserId = Guid.NewGuid(),
};
ReleaseUpdateDto dto = update.ToDto(isRead: true);
Assert.Equal("Breaking Change", dto.Category);
Assert.True(dto.IsRead);
}
[Fact]
public void VisibleTo_returns_everyone_updates_for_any_authenticated_user()
{
ReleaseUpdate update = NewPublishedUpdate(ReleaseUpdateAudience.Everyone);
List<ReleaseUpdate> visibleUpdates = new[] { update }
.AsQueryable()
.VisibleTo(new ReleaseUpdateAudienceContext(IsDeveloper: false, IsOrganizationOwner: false))
.ToList();
Assert.Same(update, Assert.Single(visibleUpdates));
}
[Fact]
public void VisibleTo_rejects_unpublished_updates()
{
ReleaseUpdate update = NewPublishedUpdate(ReleaseUpdateAudience.Everyone);
update.Status = ReleaseUpdateStatus.Draft;
List<ReleaseUpdate> visibleUpdates = new[] { update }
.AsQueryable()
.VisibleTo(new ReleaseUpdateAudienceContext(IsDeveloper: true, IsOrganizationOwner: true))
.ToList();
Assert.Empty(visibleUpdates);
}
[Fact]
public void VisibleTo_requires_matching_restricted_audience()
{
ReleaseUpdate ownerUpdate = NewPublishedUpdate(ReleaseUpdateAudience.OrganizationOwners);
ReleaseUpdate developerUpdate = NewPublishedUpdate(ReleaseUpdateAudience.Developers);
List<ReleaseUpdate> ownerVisibleUpdates = new[] { ownerUpdate, developerUpdate }
.AsQueryable()
.VisibleTo(new ReleaseUpdateAudienceContext(IsDeveloper: false, IsOrganizationOwner: true))
.ToList();
List<ReleaseUpdate> developerVisibleUpdates = new[] { ownerUpdate, developerUpdate }
.AsQueryable()
.VisibleTo(new ReleaseUpdateAudienceContext(IsDeveloper: true, IsOrganizationOwner: false))
.ToList();
Assert.Same(ownerUpdate, Assert.Single(ownerVisibleUpdates));
Assert.Same(developerUpdate, Assert.Single(developerVisibleUpdates));
}
[Fact]
public void CreateMissingReadReceipts_creates_receipts_only_for_unread_visible_updates()
{
Guid userId = Guid.NewGuid();
Guid unreadUpdateId = Guid.NewGuid();
Guid readUpdateId = Guid.NewGuid();
DateTimeOffset readAt = DateTimeOffset.UtcNow;
IReadOnlyCollection<ReleaseUpdateReadReceipt> receipts =
ReleaseUpdateReadState.CreateMissingReadReceipts(
userId,
[unreadUpdateId, readUpdateId],
new HashSet<Guid> { readUpdateId },
readAt);
ReleaseUpdateReadReceipt receipt = Assert.Single(receipts);
Assert.Equal(unreadUpdateId, receipt.ReleaseUpdateId);
Assert.Equal(userId, receipt.UserId);
Assert.Equal(readAt, receipt.ReadAt);
}
[Fact]
public void IsInactive_allows_never_authenticated_and_old_activity()
{
DateTimeOffset inactiveBefore = DateTimeOffset.UtcNow.AddHours(-24);
Assert.True(ReleaseUpdateEmailRules.IsInactive(null, inactiveBefore));
Assert.True(ReleaseUpdateEmailRules.IsInactive(inactiveBefore.AddMinutes(-1), inactiveBefore));
}
[Fact]
public void IsInactive_rejects_recent_activity()
{
DateTimeOffset inactiveBefore = DateTimeOffset.UtcNow.AddHours(-24);
Assert.False(ReleaseUpdateEmailRules.IsInactive(inactiveBefore.AddMinutes(1), inactiveBefore));
}
[Fact]
public void CanSendDigest_enforces_send_interval()
{
DateTimeOffset lastSentBefore = DateTimeOffset.UtcNow.AddHours(-24);
Assert.True(ReleaseUpdateEmailRules.CanSendDigest(null, lastSentBefore));
Assert.True(ReleaseUpdateEmailRules.CanSendDigest(lastSentBefore.AddMinutes(-1), lastSentBefore));
Assert.False(ReleaseUpdateEmailRules.CanSendDigest(lastSentBefore.AddMinutes(1), lastSentBefore));
}
private static ReleaseUpdate NewPublishedUpdate(ReleaseUpdateAudience audience)
{
return new ReleaseUpdate
{
Id = Guid.NewGuid(),
Title = "Update",
Summary = "Something changed.",
Category = ReleaseUpdateCategory.Improvement,
Importance = ReleaseUpdateImportance.Normal,
Audience = audience,
Status = ReleaseUpdateStatus.Published,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
PublishedAt = DateTimeOffset.UtcNow,
CreatedByUserId = Guid.NewGuid(),
};
}
}

View File

@@ -0,0 +1,280 @@
# Feature: Release Communications
## Status
Draft
## Goal
Give users a clear, curated view of product changes they have not seen yet, and give developers a back-office workflow for reconciling shipped commits with human-readable update entries.
The user-facing experience answers: "What changed since I last paid attention?"
The developer-facing experience answers: "Which shipped commits have been communicated, grouped, or intentionally marked internal-only?"
This feature is especially important during alpha and beta, where continuous delivery makes exact public version numbers less useful than a readable change history.
## User Stories
- As an authenticated user, I want to see new features, improvements, and fixes since my last visit so that product changes do not surprise me.
- As an organization owner, I want important updates surfaced clearly so that I can understand changes that may affect my team.
- As a developer, I want to create curated update entries so that users receive meaningful communication instead of raw commit logs.
- As a developer, I want to see shipped commits and attach them to update entries so that I can verify all relevant work has been communicated.
- As a developer, I want to mark commits as internal-only so that refactors, chores, and infrastructure work do not pollute the user-facing update feed.
- As a developer, I want optional email digests for inactive users so that alpha/beta users who do not log in still learn about important changes.
- As a developer, I want to manually send an email announcement for a published update so that I can deliberately announce important alpha/beta changes.
## Product Model
### Release Update
A release update is a curated, user-facing communication entry.
Fields:
- title
- summary
- body
- category
- importance
- audience
- status
- published timestamp
- optional deployment label, build version, or commit range
Categories:
- `Feature`
- `Improvement`
- `Fix`
- `BreakingChange`
Importance:
- `Normal`
- `Important`
Audiences:
- `Everyone`
- `OrganizationOwners`
- `Developers`
Statuses:
- `Draft`
- `Published`
- `Archived`
Only `Published` entries appear to normal users.
### Release Commit
A release commit is a developer-facing record of a Git commit that may need to be matched to a release update.
Fields:
- commit SHA
- short SHA
- subject
- author name/email
- authored timestamp
- committed timestamp
- source branch or deployment label when available
- optional pull request or external URL when available
- communication status
- optional linked release update id
Communication statuses:
- `Unreviewed`
- `Linked`
- `InternalOnly`
- `Ignored`
`Linked` commits are attached to one release update.
`InternalOnly` commits represent real shipped work that should not be visible to users, such as refactors, dependency updates, infrastructure maintenance, or test-only work.
`Ignored` is reserved for commits that should be excluded from the reconciliation view, such as merge noise or accidental imports.
### Read State
Read state is tracked per user and release update.
The system should not rely only on the user's last login timestamp. Login is one opportunity to surface unread updates, but the durable behavior is: a user has unread published release updates until those updates are marked read.
## Frontend Areas
- Whats New badge or entry in the authenticated app shell
- `/app/updates`
- Optional login-time Whats New panel for unread important/recent entries
- Developer-only release communication back office:
- `/app/developer/updates`
- `/app/developer/updates/:id`
- `/app/developer/release-commits`
Feature-owned frontend code belongs under:
```txt
frontend/src/features/release-communications/
```
## Backend Module
Backend feature code should live under:
```txt
backend/src/Socialize.Api/Modules/ReleaseCommunications/
```
Release communications are global SaaS operator data. They are not workspace-owned workflow data.
## Access Rules
- Authenticated users can list and read published release updates visible to their audience.
- Only users with the `Developer` role can create, edit, publish, archive, or delete draft release updates.
- Only users with the `Developer` role can view release commits and commit communication status.
- Developer-only entries are visible only to users with the `Developer` role.
- Organization-owner entries are visible only to users who are owners of at least one organization.
- Archived published entries remain visible in the full update history unless a future retention task changes that behavior.
## User-Facing Behavior
- The app shell shows an unread count for visible published release updates the current user has not read.
- `/app/updates` lists visible published updates sorted by newest first.
- Users can mark one update as read.
- Users can mark all visible updates as read.
- Opening an update from the unread surface should mark that update as read.
- Login may show a non-blocking Whats New panel when unread updates exist.
- Important unread updates should be easier to notice than normal unread updates.
- The update feed should not expose raw commit subjects, commit SHAs, branch names, or internal-only work.
## Developer Back Office
Developers need a reconciliation workflow that connects the real shipped commit history to curated communication entries.
The back office should support:
- create draft update entries
- edit draft entries
- publish entries
- archive published entries
- list imported commits
- filter commits by communication status
- search commits by subject, SHA, author, or linked update
- link one or more commits to an update entry
- unlink a commit from an update entry
- mark commits as internal-only
- mark commits as ignored
- show an "unreviewed commits" count
- show linked commits on update detail pages
The first implementation can import commits through an explicit submitted payload. Repository-backed import must use configured repository connection settings; the application must not assume the deployed filesystem contains a `.git` directory.
## Commit Import Rules
- Commit import should be idempotent by commit SHA.
- Imported commits should not create user-facing update entries automatically.
- Commit subjects should remain visible only in the developer back office.
- A commit can be linked to at most one release update in v1.
- A release update can link many commits.
- Imported commits default to `Unreviewed`.
- Merge commits may be imported but can be marked `Ignored`.
- Commit import should support a bounded range, such as `sinceSha..untilSha` or `sinceDate..untilDate`.
- Repository URL and access credentials belong in configuration/secrets, not hard-coded docs or code.
## Email Digest
Email is useful during alpha/beta but should be digest-based and rate-limited.
Initial email behavior:
- disabled unless explicitly enabled by configuration
- sends at most once per user per day
- sends only when the user has unread visible published updates
- sends only when the user has not logged in or opened the app in at least 24 hours
- initially targets organization owners
- includes concise summaries and a link back to the app
Developer-initiated push email behavior:
- available only to users with the `Developer` role
- sends from a published release update
- uses the release update audience to determine eligible recipients
- supports a confirmation step before sending
- supports an optional "send to me only" test mode
- records when the update was manually emailed, who sent it, and how many recipients were queued or sent
- should prevent accidental duplicate sends for the same update unless the developer explicitly confirms a resend
- should use the same concise email template as digest emails, focused on the selected update
Email delivery should use the existing email infrastructure. Do not introduce a new provider.
Email preferences are out of scope for the first email task. A later task can add user or organization notification preferences.
## API Expectations
Initial user-facing API:
```txt
GET /api/release-updates
GET /api/release-updates/unread
POST /api/release-updates/{id}/read
POST /api/release-updates/read-all
```
Initial developer API:
```txt
GET /api/developer/release-updates
POST /api/developer/release-updates
GET /api/developer/release-updates/{id}
PUT /api/developer/release-updates/{id}
POST /api/developer/release-updates/{id}/publish
POST /api/developer/release-updates/{id}/archive
POST /api/developer/release-updates/{id}/send-email
GET /api/developer/release-commits
POST /api/developer/release-commits/import
POST /api/developer/release-commits/{sha}/link
POST /api/developer/release-commits/{sha}/unlink
POST /api/developer/release-commits/{sha}/internal-only
POST /api/developer/release-commits/{sha}/ignore
```
Backend contract changes require OpenAPI regeneration while the backend is running.
## Localization
User-facing release communication UI must be available in English and French.
Developer-only back-office labels should also be localized when they appear in the app shell.
## Out Of Scope For V1
- Fully automatic update generation from Git commits
- Reading commits from the deployed app filesystem
- Public release notes pages
- Per-user or per-organization email preferences
- Scheduled publishing
- Targeting individual users or individual organizations
- Rich content blocks beyond plain text or basic markdown-style text
- Attachments or screenshots
- Multiple release streams
- Requiring semantic version numbers
- Linking one commit to multiple update entries
## Done When
- [ ] Developers can create and publish curated release updates.
- [ ] Authenticated users can see visible published updates.
- [ ] Users can tell which updates are unread.
- [ ] Users can mark updates read individually and in bulk.
- [ ] The app shell surfaces unread update counts.
- [ ] Developers can import commits into a reconciliation list.
- [ ] Developers can link commits to update entries.
- [ ] Developers can mark commits internal-only or ignored.
- [ ] User-facing update views do not expose internal commit details.
- [ ] Optional email digest and manual push-email behavior are documented and implemented in a separate task.
- [ ] Backend build and tests pass.
- [ ] Frontend build passes.
- [ ] OpenAPI is updated after backend contracts are implemented.

View File

@@ -0,0 +1,71 @@
# Task: Backend release update foundation
## Goal
Add the backend foundation for curated release update entries and per-user read state.
## Feature Spec
- `docs/FEATURES/release-communications.md`
## Scope
- Add a new FastEndpoints module under `backend/src/Socialize.Api/Modules/ReleaseCommunications`.
- Add release update data entities and EF Core model configuration.
- Add per-user release update read receipts.
- Add enum/value support for:
- category: `Feature`, `Improvement`, `Fix`, `BreakingChange`
- importance: `Normal`, `Important`
- audience: `Everyone`, `OrganizationOwners`, `Developers`
- status: `Draft`, `Published`, `Archived`
- Add `DbSet` entries and module configuration to `AppDbContext`.
- Add current-user API endpoints:
- list visible published release updates
- get unread visible release updates
- mark one release update as read
- mark all visible release updates as read
- Add developer API endpoints:
- list all release updates
- create draft release update
- get release update detail
- update draft release update
- publish release update
- archive release update
- Enforce access rules:
- authenticated users can read only visible published updates
- only `Developer` users can manage update entries
- organization-owner audience only appears to users who own at least one organization
- developer audience only appears to `Developer` users
- Keep commit import and email digest out of this task.
## Likely Files
- `backend/src/Socialize.Api/Data/AppDbContext.cs`
- `backend/src/Socialize.Api/Modules/ReleaseCommunications/**`
- `backend/tests/Socialize.Tests/**`
## Notes
- Treat release communications as global SaaS operator data, not workspace-owned workflow data.
- Use FastEndpoints handlers and keep request/response records near handlers unless local module patterns suggest otherwise.
- Use FluentValidation for non-trivial input.
- Do not expose draft entries to non-developer users.
- Do not expose commit metadata in user-facing DTOs.
## Validation
```bash
dotnet build backend/Socialize.slnx
dotnet test backend/Socialize.slnx
```
## Done When
- [x] Developers can create draft release update entries.
- [x] Developers can publish and archive release updates.
- [x] Authenticated users can list visible published updates.
- [x] Audience filtering is enforced.
- [x] Users can mark one update read.
- [x] Users can mark all visible updates read.
- [x] Unread queries only count visible published updates.
- [x] Backend tests cover access rules and read state.

View File

@@ -0,0 +1,54 @@
# Task: Frontend Whats New experience
## Goal
Add the user-facing Whats New experience for published release updates and unread state.
## Feature Spec
- `docs/FEATURES/release-communications.md`
## Scope
- Add feature-owned frontend code under `frontend/src/features/release-communications/`.
- Add `/app/updates`.
- Add an app shell entry or badge for unread release updates.
- Fetch visible published release updates from the backend.
- Show unread state for update entries.
- Mark an update as read when opened.
- Add a mark-all-read action.
- Optionally show a non-blocking login-time Whats New panel when unread updates exist.
- Add English and French locale strings.
- Keep developer authoring UI, commit reconciliation, and email digest out of this task.
## Likely Files
- `frontend/src/router/router.js`
- `frontend/src/layouts/main/**`
- `frontend/src/features/release-communications/**`
- `frontend/src/locales/en.json`
- `frontend/src/locales/fr.json`
## Notes
- The user-facing update feed must be curated and should not show raw commit SHAs, commit subjects, branch names, or internal-only work.
- Keep the UI compact and app-like. This is an operational app surface, not a marketing release notes page.
- Use the shared Axios API client in `frontend/src/plugins/api.js`.
## Validation
```bash
cd frontend
npm run build
```
## Done When
- [x] Authenticated users can open `/app/updates`.
- [x] The app shell shows unread update count.
- [x] Published visible updates are listed newest first.
- [x] Unread updates are visually distinct.
- [x] Opening an update marks it read.
- [x] Users can mark all visible updates read.
- [x] UI strings exist in English and French.
- [x] Frontend build passes.

View File

@@ -0,0 +1,80 @@
# Task: Developer commit reconciliation
## Goal
Add the developer back-office workflow for importing shipped commits and matching them to curated release update entries.
## Feature Spec
- `docs/FEATURES/release-communications.md`
## Scope
- Add release commit persistence and EF Core model configuration.
- Add enum/value support for communication status:
- `Unreviewed`
- `Linked`
- `InternalOnly`
- `Ignored`
- Add developer API endpoints:
- list imported commits
- import commits for a bounded range
- link a commit to a release update
- unlink a commit from a release update
- mark a commit internal-only
- mark a commit ignored
- Add developer-only frontend screens:
- `/app/developer/release-commits`
- linked commits on `/app/developer/updates/:id`
- Support filters for:
- communication status
- linked update
- author
- date range
- text search by subject or SHA
- Show an unreviewed commit count.
- Keep user-facing update views free of commit metadata.
- Keep automatic CI deployment integration out of this task.
## Likely Files
- `backend/src/Socialize.Api/Data/AppDbContext.cs`
- `backend/src/Socialize.Api/Modules/ReleaseCommunications/**`
- `frontend/src/router/router.js`
- `frontend/src/layouts/main/**`
- `frontend/src/features/release-communications/**`
- `frontend/src/locales/en.json`
- `frontend/src/locales/fr.json`
- `backend/tests/Socialize.Tests/**`
## Notes
- Commit import must be idempotent by SHA.
- A commit can be linked to at most one release update in v1.
- A release update can have many linked commits.
- Imported commits default to `Unreviewed`.
- Import must use either a submitted commit payload or configured repository connection settings. Do not discover or read a local `.git` directory from the deployed app filesystem.
- Repository URL and access credentials must come from configuration/secrets.
- Do not generate user-facing update entries automatically from commits.
## Validation
```bash
dotnet build backend/Socialize.slnx
dotnet test backend/Socialize.slnx
cd frontend
npm run build
```
## Done When
- [x] Developers can import commits idempotently.
- [x] Developers can list and filter imported commits.
- [x] Developers can link commits to release updates.
- [x] Developers can unlink commits.
- [x] Developers can mark commits internal-only.
- [x] Developers can mark commits ignored.
- [x] Release update detail shows linked commits to developers.
- [x] Unreviewed commit count is visible to developers.
- [x] Non-developer users cannot access commit reconciliation APIs or UI.
- [x] User-facing update views do not expose commit metadata.

View File

@@ -0,0 +1,70 @@
# Task: Release update email digest
## Goal
Add optional daily email digests for inactive users with unread release updates, plus a developer-operated manual email push for important published updates.
## Feature Spec
- `docs/FEATURES/release-communications.md`
## Scope
- Add configuration to enable or disable release update email digests.
- Add persistence needed to rate-limit digest sends per user.
- Send at most one digest per user per day.
- Send only when the user has unread visible published release updates.
- Send only when the user has not logged in or opened the app in at least 24 hours.
- Initially target organization owners.
- Use the existing email infrastructure.
- Add a developer-only API endpoint to send an email announcement for a selected published release update.
- Add a developer-only back-office button for sending the selected update by email.
- Require confirmation before sending a manual push email.
- Support a "send to me only" test mode.
- Record manual push email metadata:
- sent by user id
- sent timestamp
- selected audience
- recipient count
- Prevent accidental duplicate push sends unless the developer explicitly confirms a resend.
- Keep user or organization email preferences out of this task.
## Likely Files
- `backend/src/Socialize.Api/Infrastructure/Emailer/**`
- `backend/src/Socialize.Api/Modules/ReleaseCommunications/**`
- `backend/src/Socialize.Api/Modules/Identity/**`
- `backend/src/Socialize.Api/Modules/Organizations/**`
- `frontend/src/features/release-communications/**`
- `frontend/src/locales/en.json`
- `frontend/src/locales/fr.json`
- `backend/tests/Socialize.Tests/**`
## Notes
- This task may require tracking a user's last app activity timestamp if login timestamp alone is not enough.
- Keep email copy concise and product-specific.
- The digest should link back to the app's Whats New page.
- Manual push emails should link directly to the selected update when possible.
- Do not introduce a new email provider.
## Validation
```bash
dotnet build backend/Socialize.slnx
dotnet test backend/Socialize.slnx
```
## Done When
- [x] Digest delivery is disabled unless explicitly configured.
- [x] Eligible organization owners receive at most one digest per day.
- [x] Digests are sent only when unread visible release updates exist.
- [x] Active users are not emailed.
- [x] Developers can manually send a published update email from the back office.
- [x] Developers can send a manual update email to themselves as a test.
- [x] Manual push sends require confirmation.
- [x] Manual push sends record sender, timestamp, audience, and recipient count.
- [x] Duplicate manual sends require explicit resend confirmation.
- [x] Email delivery uses existing email infrastructure.
- [x] Backend tests cover eligibility and rate limiting.

View File

@@ -100,6 +100,246 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/developer/release-updates/{id}/archive": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["SocializeApiModulesReleaseCommunicationsHandlersArchiveDeveloperReleaseUpdateHandler"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/developer/release-updates": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["SocializeApiModulesReleaseCommunicationsHandlersListDeveloperReleaseUpdatesHandler"];
put?: never;
post: operations["SocializeApiModulesReleaseCommunicationsHandlersCreateDeveloperReleaseUpdateHandler"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/developer/release-updates/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["SocializeApiModulesReleaseCommunicationsHandlersGetDeveloperReleaseUpdateHandler"];
put: operations["SocializeApiModulesReleaseCommunicationsHandlersUpdateDeveloperReleaseUpdateHandler"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/release-updates/unread": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["SocializeApiModulesReleaseCommunicationsHandlersGetUnreadReleaseUpdatesHandler"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/developer/release-commits/import": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["SocializeApiModulesReleaseCommunicationsHandlersImportDeveloperReleaseCommitsHandler"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/developer/release-commits": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["SocializeApiModulesReleaseCommunicationsHandlersListDeveloperReleaseCommitsHandler"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/release-updates": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["SocializeApiModulesReleaseCommunicationsHandlersListReleaseUpdatesHandler"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/release-updates/read-all": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["SocializeApiModulesReleaseCommunicationsHandlersMarkAllReleaseUpdatesReadHandler"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/release-updates/{id}/read": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["SocializeApiModulesReleaseCommunicationsHandlersMarkReleaseUpdateReadHandler"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/developer/release-updates/{id}/publish": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["SocializeApiModulesReleaseCommunicationsHandlersPublishDeveloperReleaseUpdateHandler"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/developer/release-updates/{id}/send-email": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["SocializeApiModulesReleaseCommunicationsHandlersSendDeveloperReleaseUpdateEmailHandler"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/developer/release-commits/{sha}/link": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["SocializeApiModulesReleaseCommunicationsHandlersLinkDeveloperReleaseCommitHandler"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/developer/release-commits/{sha}/unlink": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["SocializeApiModulesReleaseCommunicationsHandlersUnlinkDeveloperReleaseCommitHandler"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/developer/release-commits/{sha}/internal-only": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["SocializeApiModulesReleaseCommunicationsHandlersMarkDeveloperReleaseCommitInternalOnlyHandler"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/developer/release-commits/{sha}/ignore": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["SocializeApiModulesReleaseCommunicationsHandlersIgnoreDeveloperReleaseCommitHandler"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/organizations/{organizationId}/members": { "/api/organizations/{organizationId}/members": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1227,6 +1467,131 @@ export interface components {
/** Format: int32 */ /** Format: int32 */
requiredApproverCount?: number; requiredApproverCount?: number;
}; };
SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateDto: {
/** Format: guid */
id?: string;
title?: string;
summary?: string;
body?: string | null;
category?: string;
importance?: string;
audience?: string;
status?: string;
deploymentLabel?: string | null;
buildVersion?: string | null;
commitRange?: string | null;
/** Format: date-time */
createdAt?: string;
/** Format: date-time */
updatedAt?: string;
/** Format: date-time */
publishedAt?: string | null;
/** Format: date-time */
archivedAt?: string | null;
/** Format: guid */
manualEmailSentByUserId?: string | null;
/** Format: date-time */
manualEmailSentAt?: string | null;
manualEmailAudience?: string | null;
/** Format: int32 */
manualEmailRecipientCount?: number | null;
isRead?: boolean;
};
SocializeApiModulesReleaseCommunicationsHandlersCreateDeveloperReleaseUpdateRequest: {
title: string;
summary: string;
body?: string | null;
category: string;
importance: string;
audience: string;
deploymentLabel?: string | null;
buildVersion?: string | null;
commitRange?: string | null;
};
SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateUnreadSummaryDto: {
/** Format: int32 */
unreadCount?: number;
/** Format: int32 */
importantUnreadCount?: number;
updates?: components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateDto"][];
};
SocializeApiModulesReleaseCommunicationsContractsReleaseCommitImportResultDto: {
/** Format: int32 */
importedCount?: number;
/** Format: int32 */
updatedCount?: number;
/** Format: int32 */
skippedCount?: number;
commits?: components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseCommitDto"][];
};
SocializeApiModulesReleaseCommunicationsContractsReleaseCommitDto: {
sha?: string;
shortSha?: string;
subject?: string;
authorName?: string | null;
authorEmail?: string | null;
/** Format: date-time */
authoredAt?: string | null;
/** Format: date-time */
committedAt?: string | null;
sourceBranch?: string | null;
deploymentLabel?: string | null;
externalUrl?: string | null;
communicationStatus?: string;
/** Format: guid */
releaseUpdateId?: string | null;
/** Format: date-time */
importedAt?: string;
/** Format: date-time */
updatedAt?: string;
};
SocializeApiModulesReleaseCommunicationsHandlersImportDeveloperReleaseCommitsRequest: {
sinceSha?: string | null;
untilSha?: string | null;
sourceBranch?: string | null;
deploymentLabel?: string | null;
commits?: components["schemas"]["SocializeApiModulesReleaseCommunicationsHandlersImportDeveloperReleaseCommitDto"][] | null;
};
SocializeApiModulesReleaseCommunicationsHandlersImportDeveloperReleaseCommitDto: {
sha?: string;
shortSha?: string | null;
subject?: string;
authorName?: string | null;
authorEmail?: string | null;
/** Format: date-time */
authoredAt?: string | null;
/** Format: date-time */
committedAt?: string | null;
sourceBranch?: string | null;
deploymentLabel?: string | null;
externalUrl?: string | null;
};
SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateEmailSendResultDto: {
/** Format: int32 */
recipientCount?: number;
/** Format: date-time */
sentAt?: string;
testMode?: boolean;
};
SocializeApiModulesReleaseCommunicationsHandlersSendDeveloperReleaseUpdateEmailRequest: {
testMode?: boolean;
confirmResend?: boolean;
};
SocializeApiModulesReleaseCommunicationsHandlersLinkDeveloperReleaseCommitRequest: {
/** Format: guid */
releaseUpdateId?: string;
};
SocializeApiModulesReleaseCommunicationsHandlersUpdateDeveloperReleaseUpdateRequest: {
title: string;
summary: string;
body?: string | null;
category: string;
importance: string;
audience: string;
deploymentLabel?: string | null;
buildVersion?: string | null;
commitRange?: string | null;
};
SocializeApiModulesOrganizationsHandlersOrganizationMemberDto: { SocializeApiModulesOrganizationsHandlersOrganizationMemberDto: {
/** Format: guid */ /** Format: guid */
userId?: string; userId?: string;
@@ -2277,6 +2642,610 @@ export interface operations {
}; };
}; };
}; };
SocializeApiModulesReleaseCommunicationsHandlersArchiveDeveloperReleaseUpdateHandler: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateDto"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesReleaseCommunicationsHandlersListDeveloperReleaseUpdatesHandler: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateDto"][];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesReleaseCommunicationsHandlersCreateDeveloperReleaseUpdateHandler: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsHandlersCreateDeveloperReleaseUpdateRequest"];
};
};
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateDto"];
};
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["FastEndpointsErrorResponse"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesReleaseCommunicationsHandlersGetDeveloperReleaseUpdateHandler: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateDto"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesReleaseCommunicationsHandlersUpdateDeveloperReleaseUpdateHandler: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsHandlersUpdateDeveloperReleaseUpdateRequest"];
};
};
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateDto"];
};
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["FastEndpointsErrorResponse"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesReleaseCommunicationsHandlersGetUnreadReleaseUpdatesHandler: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateUnreadSummaryDto"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesReleaseCommunicationsHandlersImportDeveloperReleaseCommitsHandler: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsHandlersImportDeveloperReleaseCommitsRequest"];
};
};
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseCommitImportResultDto"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesReleaseCommunicationsHandlersListDeveloperReleaseCommitsHandler: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseCommitDto"][];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesReleaseCommunicationsHandlersListReleaseUpdatesHandler: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateDto"][];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesReleaseCommunicationsHandlersMarkAllReleaseUpdatesReadHandler: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description No Content */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesReleaseCommunicationsHandlersMarkReleaseUpdateReadHandler: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description No Content */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesReleaseCommunicationsHandlersPublishDeveloperReleaseUpdateHandler: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateDto"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesReleaseCommunicationsHandlersSendDeveloperReleaseUpdateEmailHandler: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsHandlersSendDeveloperReleaseUpdateEmailRequest"];
};
};
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateEmailSendResultDto"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesReleaseCommunicationsHandlersLinkDeveloperReleaseCommitHandler: {
parameters: {
query?: never;
header?: never;
path: {
sha: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsHandlersLinkDeveloperReleaseCommitRequest"];
};
};
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseCommitDto"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesReleaseCommunicationsHandlersUnlinkDeveloperReleaseCommitHandler: {
parameters: {
query?: never;
header?: never;
path: {
sha: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseCommitDto"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesReleaseCommunicationsHandlersMarkDeveloperReleaseCommitInternalOnlyHandler: {
parameters: {
query?: never;
header?: never;
path: {
sha: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseCommitDto"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesReleaseCommunicationsHandlersIgnoreDeveloperReleaseCommitHandler: {
parameters: {
query?: never;
header?: never;
path: {
sha: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseCommitDto"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesOrganizationsHandlersAddOrganizationMemberHandler: { SocializeApiModulesOrganizationsHandlersAddOrganizationMemberHandler: {
parameters: { parameters: {
query?: never; query?: never;

View File

@@ -0,0 +1,245 @@
import { computed, ref } from 'vue';
import { defineStore } from 'pinia';
import { useClient } from '@/plugins/api.js';
const DEFAULT_COMMIT_FILTERS = Object.freeze({
status: '',
updateId: '',
author: '',
search: '',
});
export const RELEASE_UPDATE_CATEGORIES = ['Feature', 'Improvement', 'Fix', 'Breaking Change'];
export const RELEASE_UPDATE_IMPORTANCE = ['Normal', 'Important'];
export const RELEASE_UPDATE_AUDIENCES = ['Everyone', 'OrganizationOwners', 'Developers'];
export const RELEASE_COMMIT_STATUSES = ['Unreviewed', 'Linked', 'InternalOnly', 'Ignored'];
export const useReleaseCommunicationsStore = defineStore('release-communications', () => {
const client = useClient();
const updates = ref([]);
const unreadSummary = ref({ unreadCount: 0, importantUnreadCount: 0, updates: [] });
const developerUpdates = ref([]);
const selectedUpdate = ref(null);
const commits = ref([]);
const commitFilters = ref({ ...DEFAULT_COMMIT_FILTERS });
const isLoading = ref(false);
const isSaving = ref(false);
const isSendingEmail = ref(false);
const isImporting = ref(false);
const error = ref(null);
const unreadCount = computed(() => unreadSummary.value?.unreadCount ?? 0);
const importantUnreadCount = computed(() => unreadSummary.value?.importantUnreadCount ?? 0);
const unreviewedCommitCount = computed(() =>
commits.value.filter(commit => commit.communicationStatus === 'Unreviewed').length
);
const filteredCommits = computed(() => {
const query = commitFilters.value.search.trim().toLowerCase();
const author = commitFilters.value.author.trim().toLowerCase();
return commits.value.filter(commit => {
if (commitFilters.value.status && commit.communicationStatus !== commitFilters.value.status) {
return false;
}
if (commitFilters.value.updateId && commit.releaseUpdateId !== commitFilters.value.updateId) {
return false;
}
if (author) {
const authorText = `${commit.authorName ?? ''} ${commit.authorEmail ?? ''}`.toLowerCase();
if (!authorText.includes(author)) {
return false;
}
}
if (query) {
const haystack = [
commit.sha,
commit.shortSha,
commit.subject,
commit.authorName,
commit.authorEmail,
commit.deploymentLabel,
commit.sourceBranch,
].filter(Boolean).join(' ').toLowerCase();
if (!haystack.includes(query)) {
return false;
}
}
return true;
});
});
async function loadUserUpdates() {
isLoading.value = true;
error.value = null;
try {
const [updatesResponse, unreadResponse] = await Promise.all([
client.get('/api/release-updates'),
client.get('/api/release-updates/unread'),
]);
updates.value = updatesResponse.data ?? [];
unreadSummary.value = unreadResponse.data ?? { unreadCount: 0, importantUnreadCount: 0, updates: [] };
} catch (loadError) {
console.error('Failed to load release updates:', loadError);
error.value = 'releaseCommunications.errors.loadFailed';
throw loadError;
} finally {
isLoading.value = false;
}
}
async function markRead(id) {
await client.post(`/api/release-updates/${id}/read`);
updates.value = updates.value.map(update => update.id === id ? { ...update, isRead: true } : update);
await loadUnreadSummary();
}
async function markAllRead() {
await client.post('/api/release-updates/read-all');
updates.value = updates.value.map(update => ({ ...update, isRead: true }));
await loadUnreadSummary();
}
async function loadUnreadSummary() {
const response = await client.get('/api/release-updates/unread');
unreadSummary.value = response.data ?? { unreadCount: 0, importantUnreadCount: 0, updates: [] };
}
async function loadDeveloperUpdates() {
isLoading.value = true;
error.value = null;
try {
const response = await client.get('/api/developer/release-updates');
developerUpdates.value = response.data ?? [];
} finally {
isLoading.value = false;
}
}
async function loadDeveloperUpdate(id) {
isLoading.value = true;
try {
const response = await client.get(`/api/developer/release-updates/${id}`);
selectedUpdate.value = response.data;
return selectedUpdate.value;
} finally {
isLoading.value = false;
}
}
async function saveDeveloperUpdate(payload, id = null) {
isSaving.value = true;
try {
const response = id
? await client.put(`/api/developer/release-updates/${id}`, payload)
: await client.post('/api/developer/release-updates', payload);
selectedUpdate.value = response.data;
await loadDeveloperUpdates();
return response.data;
} finally {
isSaving.value = false;
}
}
async function publishUpdate(id) {
const response = await client.post(`/api/developer/release-updates/${id}/publish`);
selectedUpdate.value = response.data;
await loadDeveloperUpdates();
return response.data;
}
async function archiveUpdate(id) {
const response = await client.post(`/api/developer/release-updates/${id}/archive`);
selectedUpdate.value = response.data;
await loadDeveloperUpdates();
return response.data;
}
async function sendUpdateEmail(id, payload) {
isSendingEmail.value = true;
try {
return (await client.post(`/api/developer/release-updates/${id}/send-email`, payload)).data;
} finally {
isSendingEmail.value = false;
}
}
async function loadCommits() {
const response = await client.get('/api/developer/release-commits');
commits.value = response.data ?? [];
}
async function importCommits(payload) {
isImporting.value = true;
try {
const response = await client.post('/api/developer/release-commits/import', payload);
await loadCommits();
return response.data;
} finally {
isImporting.value = false;
}
}
async function linkCommit(sha, releaseUpdateId) {
await client.post(`/api/developer/release-commits/${sha}/link`, { releaseUpdateId });
await loadCommits();
}
async function unlinkCommit(sha) {
await client.post(`/api/developer/release-commits/${sha}/unlink`);
await loadCommits();
}
async function markCommitInternalOnly(sha) {
await client.post(`/api/developer/release-commits/${sha}/internal-only`);
await loadCommits();
}
async function ignoreCommit(sha) {
await client.post(`/api/developer/release-commits/${sha}/ignore`);
await loadCommits();
}
function resetCommitFilters() {
commitFilters.value = { ...DEFAULT_COMMIT_FILTERS };
}
return {
updates,
unreadSummary,
developerUpdates,
selectedUpdate,
commits,
commitFilters,
filteredCommits,
unreadCount,
importantUnreadCount,
unreviewedCommitCount,
isLoading,
isSaving,
isSendingEmail,
isImporting,
error,
loadUserUpdates,
loadUnreadSummary,
markRead,
markAllRead,
loadDeveloperUpdates,
loadDeveloperUpdate,
saveDeveloperUpdate,
publishUpdate,
archiveUpdate,
sendUpdateEmail,
loadCommits,
importCommits,
linkCommit,
unlinkCommit,
markCommitInternalOnly,
ignoreCommit,
resetCommitFilters,
};
});

View File

@@ -0,0 +1,155 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import {
RELEASE_COMMIT_STATUSES,
useReleaseCommunicationsStore,
} from '@/features/release-communications/stores/releaseCommunicationsStore.js';
const { t } = useI18n();
const store = useReleaseCommunicationsStore();
const importJson = ref('[]');
const updateOptions = computed(() =>
store.developerUpdates.map(update => ({ title: update.title, value: update.id }))
);
onMounted(async () => {
await Promise.all([store.loadDeveloperUpdates(), store.loadCommits()]);
});
async function importPayload() {
const commits = JSON.parse(importJson.value);
await store.importCommits({ commits });
}
function formatDate(value) {
return value ? new Date(value).toLocaleString() : '';
}
</script>
<template>
<section class="commits-page">
<header class="page-header">
<div>
<div class="eyebrow">{{ t('releaseCommunications.commits.eyebrow') }}</div>
<h1>{{ t('releaseCommunications.commits.title') }}</h1>
<p>{{ t('releaseCommunications.commits.description') }}</p>
</div>
<strong>{{ store.unreviewedCommitCount }} {{ t('releaseCommunications.commits.unreviewed') }}</strong>
</header>
<section class="import-panel">
<v-textarea
v-model="importJson"
:label="t('releaseCommunications.commits.importJson')"
rows="5"
variant="outlined"
/>
<v-btn :loading="store.isImporting" @click="importPayload">{{ t('releaseCommunications.commits.import') }}</v-btn>
</section>
<section class="filter-panel">
<v-text-field v-model="store.commitFilters.search" :label="t('releaseCommunications.commits.search')" density="compact" variant="outlined" hide-details />
<v-select v-model="store.commitFilters.status" :items="RELEASE_COMMIT_STATUSES" :label="t('releaseCommunications.commits.status')" density="compact" variant="outlined" hide-details clearable />
<v-select v-model="store.commitFilters.updateId" :items="updateOptions" :label="t('releaseCommunications.commits.linkedUpdate')" density="compact" variant="outlined" hide-details clearable />
<v-text-field v-model="store.commitFilters.author" :label="t('releaseCommunications.commits.author')" density="compact" variant="outlined" hide-details clearable />
<v-btn variant="outlined" @click="store.resetCommitFilters">{{ t('releaseCommunications.commits.clear') }}</v-btn>
</section>
<section class="commit-table">
<article
v-for="commit in store.filteredCommits"
:key="commit.sha"
class="commit-row"
>
<div>
<code>{{ commit.shortSha }}</code>
<strong>{{ commit.subject }}</strong>
<small>{{ commit.authorName }} / {{ formatDate(commit.committedAt) }}</small>
</div>
<span>{{ commit.communicationStatus }}</span>
<v-select
:model-value="commit.releaseUpdateId"
:items="updateOptions"
:label="t('releaseCommunications.commits.link')"
density="compact"
variant="outlined"
hide-details
clearable
@update:model-value="value => value ? store.linkCommit(commit.sha, value) : store.unlinkCommit(commit.sha)"
/>
<div class="commit-actions">
<v-btn size="small" variant="outlined" @click="store.markCommitInternalOnly(commit.sha)">{{ t('releaseCommunications.commits.internalOnly') }}</v-btn>
<v-btn size="small" variant="outlined" @click="store.ignoreCommit(commit.sha)">{{ t('releaseCommunications.commits.ignore') }}</v-btn>
</div>
</article>
</section>
</section>
</template>
<style scoped>
.commits-page {
display: grid;
gap: 18px;
padding: 24px;
}
.page-header,
.filter-panel,
.commit-row,
.commit-actions {
display: flex;
gap: 12px;
}
.page-header {
align-items: flex-start;
justify-content: space-between;
}
.eyebrow {
color: rgb(var(--v-theme-primary));
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
}
.import-panel,
.filter-panel,
.commit-row {
border: 1px solid #d8dee8;
border-radius: 8px;
background: #fff;
padding: 14px;
}
.filter-panel {
align-items: center;
}
.commit-table {
display: grid;
gap: 10px;
}
.commit-row {
align-items: center;
display: grid;
grid-template-columns: minmax(0, 1fr) 120px minmax(220px, 320px) auto;
}
.commit-row > div:first-child {
display: grid;
gap: 3px;
}
@media (max-width: 900px) {
.filter-panel,
.commit-row {
grid-template-columns: 1fr;
flex-direction: column;
align-items: stretch;
}
}
</style>

View File

@@ -0,0 +1,246 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import {
RELEASE_UPDATE_AUDIENCES,
RELEASE_UPDATE_CATEGORIES,
RELEASE_UPDATE_IMPORTANCE,
useReleaseCommunicationsStore,
} from '@/features/release-communications/stores/releaseCommunicationsStore.js';
const { t } = useI18n();
const store = useReleaseCommunicationsStore();
const editingId = ref(null);
const form = reactive({
title: '',
summary: '',
body: '',
category: 'Feature',
importance: 'Normal',
audience: 'Everyone',
deploymentLabel: '',
buildVersion: '',
commitRange: '',
});
const emailTestMode = ref(true);
const confirmResend = ref(false);
const emailResult = ref(null);
const linkedCommits = computed(() =>
editingId.value
? store.commits.filter(commit => commit.releaseUpdateId === editingId.value)
: []
);
onMounted(async () => {
await Promise.all([store.loadDeveloperUpdates(), store.loadCommits()]);
});
function editUpdate(update) {
editingId.value = update.id;
Object.assign(form, {
title: update.title ?? '',
summary: update.summary ?? '',
body: update.body ?? '',
category: update.category ?? 'Feature',
importance: update.importance ?? 'Normal',
audience: update.audience ?? 'Everyone',
deploymentLabel: update.deploymentLabel ?? '',
buildVersion: update.buildVersion ?? '',
commitRange: update.commitRange ?? '',
});
store.selectedUpdate = update;
}
function newUpdate() {
editingId.value = null;
Object.assign(form, {
title: '',
summary: '',
body: '',
category: 'Feature',
importance: 'Normal',
audience: 'Everyone',
deploymentLabel: '',
buildVersion: '',
commitRange: '',
});
emailResult.value = null;
}
async function save() {
await store.saveDeveloperUpdate({ ...form }, editingId.value);
editingId.value = store.selectedUpdate?.id ?? editingId.value;
}
async function sendEmail() {
if (!editingId.value || !window.confirm(t('releaseCommunications.developer.confirmEmail'))) {
return;
}
emailResult.value = await store.sendUpdateEmail(editingId.value, {
testMode: emailTestMode.value,
confirmResend: confirmResend.value,
});
await store.loadDeveloperUpdate(editingId.value);
await store.loadDeveloperUpdates();
}
function formatDate(value) {
return value ? new Date(value).toLocaleString() : t('releaseCommunications.emptyValue');
}
</script>
<template>
<section class="developer-updates-page">
<header class="page-header">
<div>
<div class="eyebrow">{{ t('releaseCommunications.developer.eyebrow') }}</div>
<h1>{{ t('releaseCommunications.developer.title') }}</h1>
</div>
<v-btn @click="newUpdate">{{ t('releaseCommunications.developer.newUpdate') }}</v-btn>
</header>
<section class="editor-grid">
<form
class="editor-panel"
@submit.prevent="save"
>
<v-text-field v-model="form.title" :label="t('title')" density="compact" variant="outlined" />
<v-textarea v-model="form.summary" :label="t('releaseCommunications.summary')" rows="2" variant="outlined" />
<v-textarea v-model="form.body" :label="t('releaseCommunications.body')" rows="5" variant="outlined" />
<div class="form-row">
<v-select v-model="form.category" :items="RELEASE_UPDATE_CATEGORIES" :label="t('releaseCommunications.category')" density="compact" variant="outlined" />
<v-select v-model="form.importance" :items="RELEASE_UPDATE_IMPORTANCE" :label="t('releaseCommunications.importance')" density="compact" variant="outlined" />
<v-select v-model="form.audience" :items="RELEASE_UPDATE_AUDIENCES" :label="t('releaseCommunications.audience')" density="compact" variant="outlined" />
</div>
<div class="form-row">
<v-text-field v-model="form.deploymentLabel" :label="t('releaseCommunications.deploymentLabel')" density="compact" variant="outlined" />
<v-text-field v-model="form.buildVersion" :label="t('releaseCommunications.buildVersion')" density="compact" variant="outlined" />
<v-text-field v-model="form.commitRange" :label="t('releaseCommunications.commitRange')" density="compact" variant="outlined" />
</div>
<div class="actions">
<v-btn type="submit" :loading="store.isSaving">{{ t('save') }}</v-btn>
<v-btn v-if="editingId" variant="outlined" @click="store.publishUpdate(editingId)">{{ t('releaseCommunications.developer.publish') }}</v-btn>
<v-btn v-if="editingId" variant="outlined" @click="store.archiveUpdate(editingId)">{{ t('releaseCommunications.developer.archive') }}</v-btn>
</div>
<div
v-if="editingId"
class="email-panel"
>
<strong>{{ t('releaseCommunications.developer.pushEmail') }}</strong>
<v-checkbox v-model="emailTestMode" :label="t('releaseCommunications.developer.testMode')" density="compact" hide-details />
<v-checkbox v-model="confirmResend" :label="t('releaseCommunications.developer.confirmResend')" density="compact" hide-details />
<v-btn variant="outlined" :loading="store.isSendingEmail" @click="sendEmail">{{ t('releaseCommunications.developer.sendEmail') }}</v-btn>
<small v-if="emailResult">{{ t('releaseCommunications.developer.emailResult', { count: emailResult.recipientCount }) }}</small>
</div>
</form>
<aside class="updates-panel">
<button
v-for="update in store.developerUpdates"
:key="update.id"
class="update-row"
type="button"
@click="editUpdate(update)"
>
<strong>{{ update.title }}</strong>
<span>{{ update.status }} / {{ update.audience }}</span>
<small>{{ formatDate(update.publishedAt ?? update.createdAt) }}</small>
</button>
</aside>
</section>
<section
v-if="editingId"
class="linked-commits"
>
<h2>{{ t('releaseCommunications.developer.linkedCommits') }}</h2>
<div v-if="!linkedCommits.length" class="page-message">{{ t('releaseCommunications.developer.noLinkedCommits') }}</div>
<div
v-for="commit in linkedCommits"
:key="commit.sha"
class="commit-chip"
>
<code>{{ commit.shortSha }}</code>
<span>{{ commit.subject }}</span>
</div>
</section>
</section>
</template>
<style scoped>
.developer-updates-page {
display: grid;
gap: 20px;
padding: 24px;
}
.page-header,
.actions,
.form-row {
display: flex;
gap: 12px;
}
.page-header {
align-items: center;
justify-content: space-between;
}
.eyebrow {
color: rgb(var(--v-theme-primary));
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
}
.editor-grid {
display: grid;
grid-template-columns: minmax(0, 1.3fr) minmax(280px, 0.7fr);
gap: 16px;
}
.editor-panel,
.updates-panel,
.linked-commits {
border: 1px solid #d8dee8;
border-radius: 8px;
background: #fff;
padding: 16px;
}
.update-row {
display: grid;
width: 100%;
gap: 3px;
border: 0;
border-bottom: 1px solid #e2e8f0;
background: transparent;
padding: 10px 0;
text-align: left;
}
.email-panel {
display: grid;
gap: 8px;
margin-top: 16px;
border-top: 1px solid #e2e8f0;
padding-top: 16px;
}
.commit-chip {
display: flex;
gap: 10px;
padding: 8px 0;
}
@media (max-width: 900px) {
.editor-grid,
.form-row {
grid-template-columns: 1fr;
flex-direction: column;
}
}
</style>

View File

@@ -0,0 +1,167 @@
<script setup>
import { computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { useReleaseCommunicationsStore } from '@/features/release-communications/stores/releaseCommunicationsStore.js';
const { t } = useI18n();
const route = useRoute();
const store = useReleaseCommunicationsStore();
const highlightedId = computed(() => route.query.updateId);
onMounted(async () => {
await store.loadUserUpdates();
if (highlightedId.value) {
await store.markRead(highlightedId.value);
}
});
function formatDate(value) {
return value ? new Date(value).toLocaleString() : '';
}
</script>
<template>
<section class="updates-page">
<header class="updates-header">
<div>
<div class="eyebrow">{{ t('releaseCommunications.user.eyebrow') }}</div>
<h1>{{ t('releaseCommunications.user.title') }}</h1>
<p>{{ t('releaseCommunications.user.description') }}</p>
</div>
<v-btn
variant="outlined"
:disabled="!store.unreadCount"
@click="store.markAllRead"
>
{{ t('releaseCommunications.user.markAllRead') }}
</v-btn>
</header>
<div
v-if="store.isLoading"
class="page-message"
>
{{ t('loading') }}
</div>
<section
v-else
class="updates-list"
>
<article
v-for="update in store.updates"
:key="update.id"
class="update-entry"
:class="{ 'update-entry-unread': !update.isRead, 'update-entry-highlight': update.id === highlightedId }"
@click="!update.isRead && store.markRead(update.id)"
>
<div class="update-meta">
<span>{{ update.category }}</span>
<span>{{ update.importance }}</span>
<time>{{ formatDate(update.publishedAt) }}</time>
</div>
<h2>{{ update.title }}</h2>
<p>{{ update.summary }}</p>
<div
v-if="update.body"
class="update-body"
>
{{ update.body }}
</div>
</article>
<div
v-if="!store.updates.length"
class="page-message"
>
{{ t('releaseCommunications.user.empty') }}
</div>
</section>
</section>
</template>
<style scoped>
.updates-page {
display: grid;
gap: 20px;
padding: 24px;
}
.updates-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.eyebrow,
.update-meta {
color: rgb(var(--v-theme-primary));
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
}
.updates-header h1 {
margin: 4px 0;
font-size: 1.75rem;
}
.updates-header p {
margin: 0;
color: #64748b;
}
.updates-list {
display: grid;
gap: 12px;
}
.update-entry {
border: 1px solid #d8dee8;
border-radius: 8px;
background: #fff;
padding: 16px;
}
.update-entry-unread {
border-color: rgb(var(--v-theme-primary));
box-shadow: inset 3px 0 0 rgb(var(--v-theme-primary));
cursor: pointer;
}
.update-entry-highlight {
outline: 2px solid rgb(var(--v-theme-primary));
}
.update-meta {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 8px;
color: #64748b;
}
.update-entry h2 {
margin: 0 0 6px;
font-size: 1.1rem;
}
.update-entry p {
margin: 0;
color: #334155;
}
.update-body {
margin-top: 12px;
color: #475569;
white-space: pre-line;
}
.page-message {
padding: 24px;
color: #64748b;
}
</style>

View File

@@ -5,6 +5,7 @@
import { useAuthStore } from '@/features/auth/stores/authStore.js'; import { useAuthStore } from '@/features/auth/stores/authStore.js';
import { useChannelsStore } from '@/features/channels/stores/channelsStore.js'; import { useChannelsStore } from '@/features/channels/stores/channelsStore.js';
import { useNotificationsStore } from '@/features/notifications/stores/notificationsStore.js'; import { useNotificationsStore } from '@/features/notifications/stores/notificationsStore.js';
import { useReleaseCommunicationsStore } from '@/features/release-communications/stores/releaseCommunicationsStore.js';
import { getNotificationRoute } from '@/features/notifications/notificationRoutes.js'; import { getNotificationRoute } from '@/features/notifications/notificationRoutes.js';
import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js'; import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js';
import { useCampaignsStore } from '@/features/campaigns/stores/campaignsStore.js'; import { useCampaignsStore } from '@/features/campaigns/stores/campaignsStore.js';
@@ -22,6 +23,8 @@
mdiMagnify, mdiMagnify,
mdiPlus, mdiPlus,
mdiBugOutline, mdiBugOutline,
mdiBullhornOutline,
mdiSourceCommit,
} from '@mdi/js'; } from '@mdi/js';
const props = defineProps({ const props = defineProps({
@@ -38,6 +41,7 @@
const channelsStore = useChannelsStore(); const channelsStore = useChannelsStore();
const contentItemsStore = useContentItemsStore(); const contentItemsStore = useContentItemsStore();
const notificationsStore = useNotificationsStore(); const notificationsStore = useNotificationsStore();
const releaseCommunicationsStore = useReleaseCommunicationsStore();
const campaignsStore = useCampaignsStore(); const campaignsStore = useCampaignsStore();
const isNotificationsOpen = ref(false); const isNotificationsOpen = ref(false);
const isSearchFocused = ref(false); const isSearchFocused = ref(false);
@@ -51,7 +55,10 @@
const primaryLinks = [ const primaryLinks = [
{ to: '/app/dashboard', labelKey: 'nav.overview', icon: mdiHomeOutline }, { to: '/app/dashboard', labelKey: 'nav.overview', icon: mdiHomeOutline },
{ to: '/app/media-library', labelKey: 'nav.mediaLibrary', icon: mdiImageMultipleOutline }, { to: '/app/media-library', labelKey: 'nav.mediaLibrary', icon: mdiImageMultipleOutline },
{ to: '/app/updates', labelKey: 'nav.whatsNew', icon: mdiBullhornOutline, badge: 'updates' },
{ to: '/app/feedback', labelKey: 'nav.feedbackReview', icon: mdiBugOutline, roles: ['developer'] }, { to: '/app/feedback', labelKey: 'nav.feedbackReview', icon: mdiBugOutline, roles: ['developer'] },
{ to: '/app/developer/updates', labelKey: 'nav.releaseUpdates', icon: mdiBullhornOutline, roles: ['developer'] },
{ to: '/app/developer/release-commits', labelKey: 'nav.releaseCommits', icon: mdiSourceCommit, roles: ['developer'], badge: 'commits' },
]; ];
const visiblePrimaryLinks = computed(() => const visiblePrimaryLinks = computed(() =>
primaryLinks.filter(link => !link.roles || authStore.hasAnyRole(link.roles)) primaryLinks.filter(link => !link.roles || authStore.hasAnyRole(link.roles))
@@ -231,6 +238,14 @@
); );
onMounted(() => { onMounted(() => {
releaseCommunicationsStore.loadUnreadSummary().catch(error => {
console.error('Failed to load release update unread count:', error);
});
if (authStore.hasAnyRole(['developer'])) {
releaseCommunicationsStore.loadCommits().catch(error => {
console.error('Failed to load release commit count:', error);
});
}
document.addEventListener('click', handleDocumentClick); document.addEventListener('click', handleDocumentClick);
window.addEventListener('resize', updateCollapsedSearchPanelPosition); window.addEventListener('resize', updateCollapsedSearchPanelPosition);
window.addEventListener('scroll', updateCollapsedSearchPanelPosition, true); window.addEventListener('scroll', updateCollapsedSearchPanelPosition, true);
@@ -450,7 +465,21 @@
active-class="sidebar-link-active" active-class="sidebar-link-active"
:title="!isExpanded ? t(link.labelKey) : null" :title="!isExpanded ? t(link.labelKey) : null"
> >
<span class="sidebar-link-icon-wrap">
<v-icon :icon="link.icon" /> <v-icon :icon="link.icon" />
<span
v-if="link.badge === 'updates' && releaseCommunicationsStore.unreadCount"
class="sidebar-notification-badge"
>
{{ Math.min(releaseCommunicationsStore.unreadCount, 9) }}
</span>
<span
v-if="link.badge === 'commits' && releaseCommunicationsStore.unreviewedCommitCount"
class="sidebar-notification-badge"
>
{{ Math.min(releaseCommunicationsStore.unreviewedCommitCount, 9) }}
</span>
</span>
<span <span
v-if="isExpanded" v-if="isExpanded"
class="sidebar-link-label" class="sidebar-link-label"
@@ -781,6 +810,10 @@
@apply relative flex items-center justify-center; @apply relative flex items-center justify-center;
} }
.sidebar-link-icon-wrap {
@apply relative flex items-center justify-center;
}
.sidebar-notification-badge { .sidebar-notification-badge {
@apply absolute -right-2 -top-2 flex h-5 min-w-[1.25rem] items-center justify-center rounded-full px-1 text-[10px] font-black; @apply absolute -right-2 -top-2 flex h-5 min-w-[1.25rem] items-center justify-center rounded-full px-1 text-[10px] font-black;
background: #ef4444; background: #ef4444;

View File

@@ -560,6 +560,9 @@
"mediaLibrary": "Media Library", "mediaLibrary": "Media Library",
"myFeedback": "My Feedback", "myFeedback": "My Feedback",
"feedbackReview": "Feedback Review", "feedbackReview": "Feedback Review",
"whatsNew": "What's New",
"releaseUpdates": "Release Updates",
"releaseCommits": "Release Commits",
"channels": "Channels", "channels": "Channels",
"campaigns": "Campaigns", "campaigns": "Campaigns",
"reviewQueue": "Review Queue", "reviewQueue": "Review Queue",
@@ -592,6 +595,58 @@
"feedbackReporterCommented": "Reporter replied" "feedbackReporterCommented": "Reporter replied"
} }
}, },
"releaseCommunications": {
"summary": "Summary",
"body": "Body",
"category": "Category",
"importance": "Importance",
"audience": "Audience",
"deploymentLabel": "Deployment label",
"buildVersion": "Build version",
"commitRange": "Commit range",
"emptyValue": "Not set",
"errors": {
"loadFailed": "Could not load product updates."
},
"user": {
"eyebrow": "Product updates",
"title": "What's New",
"description": "Features, improvements, and fixes published since you last checked.",
"markAllRead": "Mark all read",
"empty": "No published updates yet."
},
"developer": {
"eyebrow": "SaaS operator",
"title": "Release updates",
"newUpdate": "New update",
"publish": "Publish",
"archive": "Archive",
"pushEmail": "Push email",
"testMode": "Send to me only",
"confirmResend": "Confirm resend",
"sendEmail": "Send email",
"confirmEmail": "Send this release update by email?",
"emailResult": "Email sent to {count} recipient(s).",
"linkedCommits": "Linked commits",
"noLinkedCommits": "No commits linked to this update yet."
},
"commits": {
"eyebrow": "SaaS operator",
"title": "Release commits",
"description": "Import shipped commits and reconcile them with curated update entries.",
"unreviewed": "unreviewed",
"importJson": "Commit JSON payload",
"import": "Import commits",
"search": "Search",
"status": "Status",
"linkedUpdate": "Linked update",
"author": "Author",
"clear": "Clear",
"link": "Update",
"internalOnly": "Internal only",
"ignore": "Ignore"
}
},
"feedback": { "feedback": {
"button": "Feedback", "button": "Feedback",
"open": "Send product feedback", "open": "Send product feedback",

View File

@@ -560,6 +560,9 @@
"mediaLibrary": "Bibliotheque media", "mediaLibrary": "Bibliotheque media",
"myFeedback": "Mon feedback", "myFeedback": "Mon feedback",
"feedbackReview": "Revue feedback", "feedbackReview": "Revue feedback",
"whatsNew": "Nouveautés",
"releaseUpdates": "Mises à jour",
"releaseCommits": "Commits release",
"channels": "Canaux", "channels": "Canaux",
"campaigns": "Campagnes", "campaigns": "Campagnes",
"reviewQueue": "File de révision", "reviewQueue": "File de révision",
@@ -592,6 +595,58 @@
"feedbackReporterCommented": "Réponse du rapporteur" "feedbackReporterCommented": "Réponse du rapporteur"
} }
}, },
"releaseCommunications": {
"summary": "Résumé",
"body": "Détail",
"category": "Catégorie",
"importance": "Importance",
"audience": "Audience",
"deploymentLabel": "Libellé de déploiement",
"buildVersion": "Version build",
"commitRange": "Plage de commits",
"emptyValue": "Non défini",
"errors": {
"loadFailed": "Impossible de charger les mises à jour produit."
},
"user": {
"eyebrow": "Mises à jour produit",
"title": "Nouveautés",
"description": "Fonctionnalités, améliorations et corrections publiées depuis votre dernière visite.",
"markAllRead": "Tout marquer lu",
"empty": "Aucune mise à jour publiée pour le moment."
},
"developer": {
"eyebrow": "Opérateur SaaS",
"title": "Mises à jour release",
"newUpdate": "Nouvelle mise à jour",
"publish": "Publier",
"archive": "Archiver",
"pushEmail": "Email push",
"testMode": "M'envoyer seulement",
"confirmResend": "Confirmer le renvoi",
"sendEmail": "Envoyer email",
"confirmEmail": "Envoyer cette mise à jour par email?",
"emailResult": "Email envoyé à {count} destinataire(s).",
"linkedCommits": "Commits liés",
"noLinkedCommits": "Aucun commit lié à cette mise à jour."
},
"commits": {
"eyebrow": "Opérateur SaaS",
"title": "Commits release",
"description": "Importez les commits livrés et associez-les aux mises à jour rédigées.",
"unreviewed": "non révisés",
"importJson": "Payload JSON de commits",
"import": "Importer commits",
"search": "Recherche",
"status": "Statut",
"linkedUpdate": "Mise à jour liée",
"author": "Auteur",
"clear": "Effacer",
"link": "Mise à jour",
"internalOnly": "Interne seulement",
"ignore": "Ignorer"
}
},
"feedback": { "feedback": {
"button": "Feedback", "button": "Feedback",
"open": "Envoyer un feedback produit", "open": "Envoyer un feedback produit",

View File

@@ -36,6 +36,7 @@ import { useContentItemsStore } from '@/features/content/stores/contentItemsStor
import { useClientsStore } from '@/features/clients/stores/clientsStore.js'; import { useClientsStore } from '@/features/clients/stores/clientsStore.js';
import { useCampaignsStore } from '@/features/campaigns/stores/campaignsStore.js'; import { useCampaignsStore } from '@/features/campaigns/stores/campaignsStore.js';
import { useNotificationsStore } from '@/features/notifications/stores/notificationsStore.js'; import { useNotificationsStore } from '@/features/notifications/stores/notificationsStore.js';
import { useReleaseCommunicationsStore } from '@/features/release-communications/stores/releaseCommunicationsStore.js';
import { useChannelsStore } from '@/features/channels/stores/channelsStore.js'; import { useChannelsStore } from '@/features/channels/stores/channelsStore.js';
import { i18n } from '@/plugins/i18n.js'; import { i18n } from '@/plugins/i18n.js';
import config from '@/config.js'; import config from '@/config.js';
@@ -101,5 +102,6 @@ useChannelsStore();
useReviewQueueStore(); useReviewQueueStore();
useContentItemsStore(); useContentItemsStore();
useNotificationsStore(); useNotificationsStore();
useReleaseCommunicationsStore();
app.mount('#app'); app.mount('#app');

View File

@@ -33,6 +33,9 @@ const MyFeedbackListView = () => import('@/features/feedback/views/MyFeedbackLis
const MyFeedbackDetailView = () => import('@/features/feedback/views/MyFeedbackDetailView.vue'); const MyFeedbackDetailView = () => import('@/features/feedback/views/MyFeedbackDetailView.vue');
const DeveloperFeedbackListView = () => import('@/features/feedback/views/DeveloperFeedbackListView.vue'); const DeveloperFeedbackListView = () => import('@/features/feedback/views/DeveloperFeedbackListView.vue');
const DeveloperFeedbackDetailView = () => import('@/features/feedback/views/DeveloperFeedbackDetailView.vue'); const DeveloperFeedbackDetailView = () => import('@/features/feedback/views/DeveloperFeedbackDetailView.vue');
const UpdatesView = () => import('@/features/release-communications/views/UpdatesView.vue');
const DeveloperUpdatesView = () => import('@/features/release-communications/views/DeveloperUpdatesView.vue');
const DeveloperReleaseCommitsView = () => import('@/features/release-communications/views/DeveloperReleaseCommitsView.vue');
const routes = [ const routes = [
{ {
@@ -123,6 +126,24 @@ const routes = [
component: MyFeedbackDetailView, component: MyFeedbackDetailView,
meta: { requiresAuth: true }, meta: { requiresAuth: true },
}, },
{
path: '/app/updates',
name: 'release-updates',
component: UpdatesView,
meta: { requiresAuth: true },
},
{
path: '/app/developer/updates',
name: 'developer-release-updates',
component: DeveloperUpdatesView,
meta: { requiresAuth: true, roles: ['developer'] },
},
{
path: '/app/developer/release-commits',
name: 'developer-release-commits',
component: DeveloperReleaseCommitsView,
meta: { requiresAuth: true, roles: ['developer'] },
},
{ {
path: '/app/feedback', path: '/app/feedback',
name: 'developer-feedback', name: 'developer-feedback',

File diff suppressed because it is too large Load Diff