feat: add release communications
This commit is contained in:
@@ -12,6 +12,7 @@ using Socialize.Api.Modules.Notifications.Data;
|
||||
using Socialize.Api.Modules.Campaigns.Data;
|
||||
using Socialize.Api.Modules.CalendarIntegrations.Data;
|
||||
using Socialize.Api.Modules.Organizations.Data;
|
||||
using Socialize.Api.Modules.ReleaseCommunications.Data;
|
||||
using Socialize.Api.Modules.Workspaces.Data;
|
||||
|
||||
namespace Socialize.Api.Data;
|
||||
@@ -50,6 +51,10 @@ internal class AppDbContext(
|
||||
public DbSet<CalendarCatalogEntry> CalendarCatalogEntries => Set<CalendarCatalogEntry>();
|
||||
public DbSet<CalendarEvent> CalendarEvents => Set<CalendarEvent>();
|
||||
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)
|
||||
{
|
||||
@@ -67,5 +72,6 @@ internal class AppDbContext(
|
||||
builder.ConfigureNotificationsModule();
|
||||
builder.ConfigureFeedbackModule();
|
||||
builder.ConfigureCalendarIntegrationsModule();
|
||||
builder.ConfigureReleaseCommunicationsModule();
|
||||
}
|
||||
}
|
||||
|
||||
2628
backend/src/Socialize.Api/Migrations/20260508010206_AddReleaseCommunications.Designer.cs
generated
Normal file
2628
backend/src/Socialize.Api/Migrations/20260508010206_AddReleaseCommunications.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1522,6 +1522,9 @@ namespace Socialize.Api.Migrations
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastAuthenticatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Lastname")
|
||||
.HasMaxLength(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 =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -2345,6 +2565,27 @@ namespace Socialize.Api.Migrations
|
||||
.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 =>
|
||||
{
|
||||
b.HasOne("Socialize.Api.Modules.Organizations.Data.Organization", null)
|
||||
@@ -2373,6 +2614,11 @@ namespace Socialize.Api.Migrations
|
||||
|
||||
b.Navigation("Tags");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Socialize.Api.Modules.ReleaseCommunications.Data.ReleaseUpdate", b =>
|
||||
{
|
||||
b.Navigation("ReadReceipts");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,5 +15,6 @@ internal class User : IdentityUser<Guid>
|
||||
[MaxLength(256)] public string? FacebookId { get; set; }
|
||||
[MaxLength(44)] public string? RefreshToken { get; set; }
|
||||
public DateTime RefreshTokenExpiryTime { get; set; }
|
||||
public DateTimeOffset? LastAuthenticatedAt { get; set; }
|
||||
public string Fullname => $"{Lastname}, {Firstname}";
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ internal class LoginHandler(
|
||||
// Generate a new refresh token
|
||||
user.RefreshToken = RefreshTokenGenerator.Next();
|
||||
user.RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime);
|
||||
user.LastAuthenticatedAt = DateTimeOffset.UtcNow;
|
||||
await userManager.UpdateAsync(user);
|
||||
|
||||
// Generate JWT token
|
||||
|
||||
@@ -99,7 +99,8 @@ internal class LoginWithFacebookHandler(
|
||||
Lastname = userInfo.Name.Split(' ').Skip(1).FirstOrDefault() ?? "",
|
||||
Alias = userInfo.Name,
|
||||
PortraitUrl = userInfo.Picture.Picture.Url,
|
||||
FacebookId = userInfo.Id // Storing Facebook ID
|
||||
FacebookId = userInfo.Id, // Storing Facebook ID
|
||||
LastAuthenticatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
IdentityResult result = await userManager.CreateAsync(
|
||||
@@ -124,6 +125,7 @@ internal class LoginWithFacebookHandler(
|
||||
// Store refresh token in user's properties
|
||||
user.RefreshToken = refreshToken;
|
||||
user.RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime);
|
||||
user.LastAuthenticatedAt = DateTimeOffset.UtcNow;
|
||||
await userManager.UpdateAsync(user);
|
||||
|
||||
string accessToken = await accessTokenFactory.CreateAsync(user);
|
||||
|
||||
@@ -106,7 +106,8 @@ internal class LoginWithGoogleHandler(
|
||||
PortraitUrl = userInfo.Picture,
|
||||
GoogleId = userInfo.Id,
|
||||
RefreshToken = refreshToken,
|
||||
RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime)
|
||||
RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime),
|
||||
LastAuthenticatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
IdentityResult result = await userManager.CreateAsync(
|
||||
@@ -128,6 +129,7 @@ internal class LoginWithGoogleHandler(
|
||||
// Generate the new refresh token
|
||||
user.RefreshToken = RefreshTokenGenerator.Next();
|
||||
user.RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime);
|
||||
user.LastAuthenticatedAt = DateTimeOffset.UtcNow;
|
||||
await userManager.UpdateAsync(user);
|
||||
|
||||
string accessToken = await accessTokenFactory.CreateAsync(user);
|
||||
|
||||
@@ -53,6 +53,7 @@ internal class RefreshTokenHandler(
|
||||
|
||||
// Update refresh token expiry time
|
||||
user.RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime);
|
||||
user.LastAuthenticatedAt = DateTimeOffset.UtcNow;
|
||||
await userManager.UpdateAsync(user);
|
||||
|
||||
// Generate a new access token
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Socialize.Api.Modules.ReleaseCommunications.Data;
|
||||
|
||||
internal enum ReleaseCommitCommunicationStatus
|
||||
{
|
||||
Unreviewed,
|
||||
Linked,
|
||||
InternalOnly,
|
||||
Ignored,
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Socialize.Api.Modules.ReleaseCommunications.Data;
|
||||
|
||||
internal enum ReleaseUpdateAudience
|
||||
{
|
||||
Everyone,
|
||||
OrganizationOwners,
|
||||
Developers,
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Socialize.Api.Modules.ReleaseCommunications.Data;
|
||||
|
||||
internal enum ReleaseUpdateCategory
|
||||
{
|
||||
Feature,
|
||||
Improvement,
|
||||
Fix,
|
||||
BreakingChange,
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Socialize.Api.Modules.ReleaseCommunications.Data;
|
||||
|
||||
internal enum ReleaseUpdateImportance
|
||||
{
|
||||
Normal,
|
||||
Important,
|
||||
}
|
||||
@@ -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!;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Socialize.Api.Modules.ReleaseCommunications.Data;
|
||||
|
||||
internal enum ReleaseUpdateStatus
|
||||
{
|
||||
Draft,
|
||||
Published,
|
||||
Archived,
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -19,6 +19,7 @@ using Socialize.Api.Modules.Notifications;
|
||||
using Socialize.Api.Modules.Campaigns;
|
||||
using Socialize.Api.Modules.CalendarIntegrations;
|
||||
using Socialize.Api.Modules.Organizations;
|
||||
using Socialize.Api.Modules.ReleaseCommunications;
|
||||
using Socialize.Api.Modules.Workspaces;
|
||||
|
||||
|
||||
@@ -78,6 +79,7 @@ builder.AddApprovalsModule();
|
||||
builder.AddNotificationsModule();
|
||||
builder.AddFeedbackModule();
|
||||
builder.AddCalendarIntegrationsModule();
|
||||
builder.AddReleaseCommunicationsModule();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user