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.Campaigns.Data;
|
||||||
using Socialize.Api.Modules.CalendarIntegrations.Data;
|
using Socialize.Api.Modules.CalendarIntegrations.Data;
|
||||||
using Socialize.Api.Modules.Organizations.Data;
|
using Socialize.Api.Modules.Organizations.Data;
|
||||||
|
using Socialize.Api.Modules.ReleaseCommunications.Data;
|
||||||
using Socialize.Api.Modules.Workspaces.Data;
|
using Socialize.Api.Modules.Workspaces.Data;
|
||||||
|
|
||||||
namespace Socialize.Api.Data;
|
namespace Socialize.Api.Data;
|
||||||
@@ -50,6 +51,10 @@ internal class AppDbContext(
|
|||||||
public DbSet<CalendarCatalogEntry> CalendarCatalogEntries => Set<CalendarCatalogEntry>();
|
public DbSet<CalendarCatalogEntry> CalendarCatalogEntries => Set<CalendarCatalogEntry>();
|
||||||
public DbSet<CalendarEvent> CalendarEvents => Set<CalendarEvent>();
|
public DbSet<CalendarEvent> CalendarEvents => Set<CalendarEvent>();
|
||||||
public DbSet<UserCalendarExportFeed> UserCalendarExportFeeds => Set<UserCalendarExportFeed>();
|
public DbSet<UserCalendarExportFeed> UserCalendarExportFeeds => Set<UserCalendarExportFeed>();
|
||||||
|
public DbSet<ReleaseUpdate> ReleaseUpdates => Set<ReleaseUpdate>();
|
||||||
|
public DbSet<ReleaseUpdateReadReceipt> ReleaseUpdateReadReceipts => Set<ReleaseUpdateReadReceipt>();
|
||||||
|
public DbSet<ReleaseCommit> ReleaseCommits => Set<ReleaseCommit>();
|
||||||
|
public DbSet<ReleaseUpdateEmailDigestReceipt> ReleaseUpdateEmailDigestReceipts => Set<ReleaseUpdateEmailDigestReceipt>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder builder)
|
protected override void OnModelCreating(ModelBuilder builder)
|
||||||
{
|
{
|
||||||
@@ -67,5 +72,6 @@ internal class AppDbContext(
|
|||||||
builder.ConfigureNotificationsModule();
|
builder.ConfigureNotificationsModule();
|
||||||
builder.ConfigureFeedbackModule();
|
builder.ConfigureFeedbackModule();
|
||||||
builder.ConfigureCalendarIntegrationsModule();
|
builder.ConfigureCalendarIntegrationsModule();
|
||||||
|
builder.ConfigureReleaseCommunicationsModule();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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)
|
.HasMaxLength(256)
|
||||||
.HasColumnType("character varying(256)");
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LastAuthenticatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
b.Property<string>("Lastname")
|
b.Property<string>("Lastname")
|
||||||
.HasMaxLength(256)
|
.HasMaxLength(256)
|
||||||
.HasColumnType("character varying(256)");
|
.HasColumnType("character varying(256)");
|
||||||
@@ -1899,6 +1902,223 @@ namespace Socialize.Api.Migrations
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Socialize.Api.Modules.ReleaseCommunications.Data.ReleaseCommit", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Sha")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("AuthorEmail")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("AuthorName")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("AuthoredAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("CommittedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("CommunicationStatus")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)");
|
||||||
|
|
||||||
|
b.Property<string>("DeploymentLabel")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<string>("ExternalUrl")
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("ImportedAt")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ReleaseUpdateId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("ShortSha")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("character varying(16)");
|
||||||
|
|
||||||
|
b.Property<string>("SourceBranch")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("Subject")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("character varying(512)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Sha");
|
||||||
|
|
||||||
|
b.HasIndex("CommittedAt");
|
||||||
|
|
||||||
|
b.HasIndex("CommunicationStatus");
|
||||||
|
|
||||||
|
b.HasIndex("ReleaseUpdateId");
|
||||||
|
|
||||||
|
b.ToTable("ReleaseCommits", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Socialize.Api.Modules.ReleaseCommunications.Data.ReleaseUpdate", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("ArchivedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Audience")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)");
|
||||||
|
|
||||||
|
b.Property<string>("Body")
|
||||||
|
.HasMaxLength(8000)
|
||||||
|
.HasColumnType("character varying(8000)");
|
||||||
|
|
||||||
|
b.Property<string>("BuildVersion")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<string>("Category")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)");
|
||||||
|
|
||||||
|
b.Property<string>("CommitRange")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||||
|
|
||||||
|
b.Property<Guid>("CreatedByUserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("DeploymentLabel")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<string>("Importance")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)");
|
||||||
|
|
||||||
|
b.Property<string>("ManualEmailAudience")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<int?>("ManualEmailRecipientCount")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("ManualEmailSentAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ManualEmailSentByUserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("PublishedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)");
|
||||||
|
|
||||||
|
b.Property<string>("Summary")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("character varying(512)");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(160)
|
||||||
|
.HasColumnType("character varying(160)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Audience");
|
||||||
|
|
||||||
|
b.HasIndex("CreatedByUserId");
|
||||||
|
|
||||||
|
b.HasIndex("PublishedAt");
|
||||||
|
|
||||||
|
b.HasIndex("Status");
|
||||||
|
|
||||||
|
b.ToTable("ReleaseUpdates", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Socialize.Api.Modules.ReleaseCommunications.Data.ReleaseUpdateEmailDigestReceipt", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("SentAt")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||||
|
|
||||||
|
b.Property<int>("UpdateCount")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("SentAt");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("ReleaseUpdateEmailDigestReceipts", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Socialize.Api.Modules.ReleaseCommunications.Data.ReleaseUpdateReadReceipt", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("ReadAt")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||||
|
|
||||||
|
b.Property<Guid>("ReleaseUpdateId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.HasIndex("ReleaseUpdateId", "UserId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("ReleaseUpdateReadReceipts", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Socialize.Api.Modules.Workspaces.Data.Workspace", b =>
|
modelBuilder.Entity("Socialize.Api.Modules.Workspaces.Data.Workspace", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -2345,6 +2565,27 @@ namespace Socialize.Api.Migrations
|
|||||||
.IsRequired();
|
.IsRequired();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Socialize.Api.Modules.ReleaseCommunications.Data.ReleaseCommit", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Socialize.Api.Modules.ReleaseCommunications.Data.ReleaseUpdate", "ReleaseUpdate")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ReleaseUpdateId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.Navigation("ReleaseUpdate");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Socialize.Api.Modules.ReleaseCommunications.Data.ReleaseUpdateReadReceipt", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Socialize.Api.Modules.ReleaseCommunications.Data.ReleaseUpdate", "ReleaseUpdate")
|
||||||
|
.WithMany("ReadReceipts")
|
||||||
|
.HasForeignKey("ReleaseUpdateId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("ReleaseUpdate");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Socialize.Api.Modules.Workspaces.Data.Workspace", b =>
|
modelBuilder.Entity("Socialize.Api.Modules.Workspaces.Data.Workspace", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Socialize.Api.Modules.Organizations.Data.Organization", null)
|
b.HasOne("Socialize.Api.Modules.Organizations.Data.Organization", null)
|
||||||
@@ -2373,6 +2614,11 @@ namespace Socialize.Api.Migrations
|
|||||||
|
|
||||||
b.Navigation("Tags");
|
b.Navigation("Tags");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Socialize.Api.Modules.ReleaseCommunications.Data.ReleaseUpdate", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("ReadReceipts");
|
||||||
|
});
|
||||||
#pragma warning restore 612, 618
|
#pragma warning restore 612, 618
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,5 +15,6 @@ internal class User : IdentityUser<Guid>
|
|||||||
[MaxLength(256)] public string? FacebookId { get; set; }
|
[MaxLength(256)] public string? FacebookId { get; set; }
|
||||||
[MaxLength(44)] public string? RefreshToken { get; set; }
|
[MaxLength(44)] public string? RefreshToken { get; set; }
|
||||||
public DateTime RefreshTokenExpiryTime { get; set; }
|
public DateTime RefreshTokenExpiryTime { get; set; }
|
||||||
|
public DateTimeOffset? LastAuthenticatedAt { get; set; }
|
||||||
public string Fullname => $"{Lastname}, {Firstname}";
|
public string Fullname => $"{Lastname}, {Firstname}";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ internal class LoginHandler(
|
|||||||
// Generate a new refresh token
|
// Generate a new refresh token
|
||||||
user.RefreshToken = RefreshTokenGenerator.Next();
|
user.RefreshToken = RefreshTokenGenerator.Next();
|
||||||
user.RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime);
|
user.RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime);
|
||||||
|
user.LastAuthenticatedAt = DateTimeOffset.UtcNow;
|
||||||
await userManager.UpdateAsync(user);
|
await userManager.UpdateAsync(user);
|
||||||
|
|
||||||
// Generate JWT token
|
// Generate JWT token
|
||||||
|
|||||||
@@ -99,7 +99,8 @@ internal class LoginWithFacebookHandler(
|
|||||||
Lastname = userInfo.Name.Split(' ').Skip(1).FirstOrDefault() ?? "",
|
Lastname = userInfo.Name.Split(' ').Skip(1).FirstOrDefault() ?? "",
|
||||||
Alias = userInfo.Name,
|
Alias = userInfo.Name,
|
||||||
PortraitUrl = userInfo.Picture.Picture.Url,
|
PortraitUrl = userInfo.Picture.Picture.Url,
|
||||||
FacebookId = userInfo.Id // Storing Facebook ID
|
FacebookId = userInfo.Id, // Storing Facebook ID
|
||||||
|
LastAuthenticatedAt = DateTimeOffset.UtcNow,
|
||||||
};
|
};
|
||||||
|
|
||||||
IdentityResult result = await userManager.CreateAsync(
|
IdentityResult result = await userManager.CreateAsync(
|
||||||
@@ -124,6 +125,7 @@ internal class LoginWithFacebookHandler(
|
|||||||
// Store refresh token in user's properties
|
// Store refresh token in user's properties
|
||||||
user.RefreshToken = refreshToken;
|
user.RefreshToken = refreshToken;
|
||||||
user.RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime);
|
user.RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime);
|
||||||
|
user.LastAuthenticatedAt = DateTimeOffset.UtcNow;
|
||||||
await userManager.UpdateAsync(user);
|
await userManager.UpdateAsync(user);
|
||||||
|
|
||||||
string accessToken = await accessTokenFactory.CreateAsync(user);
|
string accessToken = await accessTokenFactory.CreateAsync(user);
|
||||||
|
|||||||
@@ -106,7 +106,8 @@ internal class LoginWithGoogleHandler(
|
|||||||
PortraitUrl = userInfo.Picture,
|
PortraitUrl = userInfo.Picture,
|
||||||
GoogleId = userInfo.Id,
|
GoogleId = userInfo.Id,
|
||||||
RefreshToken = refreshToken,
|
RefreshToken = refreshToken,
|
||||||
RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime)
|
RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime),
|
||||||
|
LastAuthenticatedAt = DateTimeOffset.UtcNow,
|
||||||
};
|
};
|
||||||
|
|
||||||
IdentityResult result = await userManager.CreateAsync(
|
IdentityResult result = await userManager.CreateAsync(
|
||||||
@@ -128,6 +129,7 @@ internal class LoginWithGoogleHandler(
|
|||||||
// Generate the new refresh token
|
// Generate the new refresh token
|
||||||
user.RefreshToken = RefreshTokenGenerator.Next();
|
user.RefreshToken = RefreshTokenGenerator.Next();
|
||||||
user.RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime);
|
user.RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime);
|
||||||
|
user.LastAuthenticatedAt = DateTimeOffset.UtcNow;
|
||||||
await userManager.UpdateAsync(user);
|
await userManager.UpdateAsync(user);
|
||||||
|
|
||||||
string accessToken = await accessTokenFactory.CreateAsync(user);
|
string accessToken = await accessTokenFactory.CreateAsync(user);
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ internal class RefreshTokenHandler(
|
|||||||
|
|
||||||
// Update refresh token expiry time
|
// Update refresh token expiry time
|
||||||
user.RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime);
|
user.RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime);
|
||||||
|
user.LastAuthenticatedAt = DateTimeOffset.UtcNow;
|
||||||
await userManager.UpdateAsync(user);
|
await userManager.UpdateAsync(user);
|
||||||
|
|
||||||
// Generate a new access token
|
// Generate a new access token
|
||||||
|
|||||||
@@ -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.Campaigns;
|
||||||
using Socialize.Api.Modules.CalendarIntegrations;
|
using Socialize.Api.Modules.CalendarIntegrations;
|
||||||
using Socialize.Api.Modules.Organizations;
|
using Socialize.Api.Modules.Organizations;
|
||||||
|
using Socialize.Api.Modules.ReleaseCommunications;
|
||||||
using Socialize.Api.Modules.Workspaces;
|
using Socialize.Api.Modules.Workspaces;
|
||||||
|
|
||||||
|
|
||||||
@@ -78,6 +79,7 @@ builder.AddApprovalsModule();
|
|||||||
builder.AddNotificationsModule();
|
builder.AddNotificationsModule();
|
||||||
builder.AddFeedbackModule();
|
builder.AddFeedbackModule();
|
||||||
builder.AddCalendarIntegrationsModule();
|
builder.AddCalendarIntegrationsModule();
|
||||||
|
builder.AddReleaseCommunicationsModule();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
280
docs/FEATURES/release-communications.md
Normal file
280
docs/FEATURES/release-communications.md
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
# Feature: Release Communications
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Draft
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Give users a clear, curated view of product changes they have not seen yet, and give developers a back-office workflow for reconciling shipped commits with human-readable update entries.
|
||||||
|
|
||||||
|
The user-facing experience answers: "What changed since I last paid attention?"
|
||||||
|
|
||||||
|
The developer-facing experience answers: "Which shipped commits have been communicated, grouped, or intentionally marked internal-only?"
|
||||||
|
|
||||||
|
This feature is especially important during alpha and beta, where continuous delivery makes exact public version numbers less useful than a readable change history.
|
||||||
|
|
||||||
|
## User Stories
|
||||||
|
|
||||||
|
- As an authenticated user, I want to see new features, improvements, and fixes since my last visit so that product changes do not surprise me.
|
||||||
|
- As an organization owner, I want important updates surfaced clearly so that I can understand changes that may affect my team.
|
||||||
|
- As a developer, I want to create curated update entries so that users receive meaningful communication instead of raw commit logs.
|
||||||
|
- As a developer, I want to see shipped commits and attach them to update entries so that I can verify all relevant work has been communicated.
|
||||||
|
- As a developer, I want to mark commits as internal-only so that refactors, chores, and infrastructure work do not pollute the user-facing update feed.
|
||||||
|
- As a developer, I want optional email digests for inactive users so that alpha/beta users who do not log in still learn about important changes.
|
||||||
|
- As a developer, I want to manually send an email announcement for a published update so that I can deliberately announce important alpha/beta changes.
|
||||||
|
|
||||||
|
## Product Model
|
||||||
|
|
||||||
|
### Release Update
|
||||||
|
|
||||||
|
A release update is a curated, user-facing communication entry.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
|
||||||
|
- title
|
||||||
|
- summary
|
||||||
|
- body
|
||||||
|
- category
|
||||||
|
- importance
|
||||||
|
- audience
|
||||||
|
- status
|
||||||
|
- published timestamp
|
||||||
|
- optional deployment label, build version, or commit range
|
||||||
|
|
||||||
|
Categories:
|
||||||
|
|
||||||
|
- `Feature`
|
||||||
|
- `Improvement`
|
||||||
|
- `Fix`
|
||||||
|
- `BreakingChange`
|
||||||
|
|
||||||
|
Importance:
|
||||||
|
|
||||||
|
- `Normal`
|
||||||
|
- `Important`
|
||||||
|
|
||||||
|
Audiences:
|
||||||
|
|
||||||
|
- `Everyone`
|
||||||
|
- `OrganizationOwners`
|
||||||
|
- `Developers`
|
||||||
|
|
||||||
|
Statuses:
|
||||||
|
|
||||||
|
- `Draft`
|
||||||
|
- `Published`
|
||||||
|
- `Archived`
|
||||||
|
|
||||||
|
Only `Published` entries appear to normal users.
|
||||||
|
|
||||||
|
### Release Commit
|
||||||
|
|
||||||
|
A release commit is a developer-facing record of a Git commit that may need to be matched to a release update.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
|
||||||
|
- commit SHA
|
||||||
|
- short SHA
|
||||||
|
- subject
|
||||||
|
- author name/email
|
||||||
|
- authored timestamp
|
||||||
|
- committed timestamp
|
||||||
|
- source branch or deployment label when available
|
||||||
|
- optional pull request or external URL when available
|
||||||
|
- communication status
|
||||||
|
- optional linked release update id
|
||||||
|
|
||||||
|
Communication statuses:
|
||||||
|
|
||||||
|
- `Unreviewed`
|
||||||
|
- `Linked`
|
||||||
|
- `InternalOnly`
|
||||||
|
- `Ignored`
|
||||||
|
|
||||||
|
`Linked` commits are attached to one release update.
|
||||||
|
|
||||||
|
`InternalOnly` commits represent real shipped work that should not be visible to users, such as refactors, dependency updates, infrastructure maintenance, or test-only work.
|
||||||
|
|
||||||
|
`Ignored` is reserved for commits that should be excluded from the reconciliation view, such as merge noise or accidental imports.
|
||||||
|
|
||||||
|
### Read State
|
||||||
|
|
||||||
|
Read state is tracked per user and release update.
|
||||||
|
|
||||||
|
The system should not rely only on the user's last login timestamp. Login is one opportunity to surface unread updates, but the durable behavior is: a user has unread published release updates until those updates are marked read.
|
||||||
|
|
||||||
|
## Frontend Areas
|
||||||
|
|
||||||
|
- What’s New badge or entry in the authenticated app shell
|
||||||
|
- `/app/updates`
|
||||||
|
- Optional login-time What’s New panel for unread important/recent entries
|
||||||
|
- Developer-only release communication back office:
|
||||||
|
- `/app/developer/updates`
|
||||||
|
- `/app/developer/updates/:id`
|
||||||
|
- `/app/developer/release-commits`
|
||||||
|
|
||||||
|
Feature-owned frontend code belongs under:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
frontend/src/features/release-communications/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backend Module
|
||||||
|
|
||||||
|
Backend feature code should live under:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
backend/src/Socialize.Api/Modules/ReleaseCommunications/
|
||||||
|
```
|
||||||
|
|
||||||
|
Release communications are global SaaS operator data. They are not workspace-owned workflow data.
|
||||||
|
|
||||||
|
## Access Rules
|
||||||
|
|
||||||
|
- Authenticated users can list and read published release updates visible to their audience.
|
||||||
|
- Only users with the `Developer` role can create, edit, publish, archive, or delete draft release updates.
|
||||||
|
- Only users with the `Developer` role can view release commits and commit communication status.
|
||||||
|
- Developer-only entries are visible only to users with the `Developer` role.
|
||||||
|
- Organization-owner entries are visible only to users who are owners of at least one organization.
|
||||||
|
- Archived published entries remain visible in the full update history unless a future retention task changes that behavior.
|
||||||
|
|
||||||
|
## User-Facing Behavior
|
||||||
|
|
||||||
|
- The app shell shows an unread count for visible published release updates the current user has not read.
|
||||||
|
- `/app/updates` lists visible published updates sorted by newest first.
|
||||||
|
- Users can mark one update as read.
|
||||||
|
- Users can mark all visible updates as read.
|
||||||
|
- Opening an update from the unread surface should mark that update as read.
|
||||||
|
- Login may show a non-blocking What’s New panel when unread updates exist.
|
||||||
|
- Important unread updates should be easier to notice than normal unread updates.
|
||||||
|
- The update feed should not expose raw commit subjects, commit SHAs, branch names, or internal-only work.
|
||||||
|
|
||||||
|
## Developer Back Office
|
||||||
|
|
||||||
|
Developers need a reconciliation workflow that connects the real shipped commit history to curated communication entries.
|
||||||
|
|
||||||
|
The back office should support:
|
||||||
|
|
||||||
|
- create draft update entries
|
||||||
|
- edit draft entries
|
||||||
|
- publish entries
|
||||||
|
- archive published entries
|
||||||
|
- list imported commits
|
||||||
|
- filter commits by communication status
|
||||||
|
- search commits by subject, SHA, author, or linked update
|
||||||
|
- link one or more commits to an update entry
|
||||||
|
- unlink a commit from an update entry
|
||||||
|
- mark commits as internal-only
|
||||||
|
- mark commits as ignored
|
||||||
|
- show an "unreviewed commits" count
|
||||||
|
- show linked commits on update detail pages
|
||||||
|
|
||||||
|
The first implementation can import commits through an explicit submitted payload. Repository-backed import must use configured repository connection settings; the application must not assume the deployed filesystem contains a `.git` directory.
|
||||||
|
|
||||||
|
## Commit Import Rules
|
||||||
|
|
||||||
|
- Commit import should be idempotent by commit SHA.
|
||||||
|
- Imported commits should not create user-facing update entries automatically.
|
||||||
|
- Commit subjects should remain visible only in the developer back office.
|
||||||
|
- A commit can be linked to at most one release update in v1.
|
||||||
|
- A release update can link many commits.
|
||||||
|
- Imported commits default to `Unreviewed`.
|
||||||
|
- Merge commits may be imported but can be marked `Ignored`.
|
||||||
|
- Commit import should support a bounded range, such as `sinceSha..untilSha` or `sinceDate..untilDate`.
|
||||||
|
- Repository URL and access credentials belong in configuration/secrets, not hard-coded docs or code.
|
||||||
|
|
||||||
|
## Email Digest
|
||||||
|
|
||||||
|
Email is useful during alpha/beta but should be digest-based and rate-limited.
|
||||||
|
|
||||||
|
Initial email behavior:
|
||||||
|
|
||||||
|
- disabled unless explicitly enabled by configuration
|
||||||
|
- sends at most once per user per day
|
||||||
|
- sends only when the user has unread visible published updates
|
||||||
|
- sends only when the user has not logged in or opened the app in at least 24 hours
|
||||||
|
- initially targets organization owners
|
||||||
|
- includes concise summaries and a link back to the app
|
||||||
|
|
||||||
|
Developer-initiated push email behavior:
|
||||||
|
|
||||||
|
- available only to users with the `Developer` role
|
||||||
|
- sends from a published release update
|
||||||
|
- uses the release update audience to determine eligible recipients
|
||||||
|
- supports a confirmation step before sending
|
||||||
|
- supports an optional "send to me only" test mode
|
||||||
|
- records when the update was manually emailed, who sent it, and how many recipients were queued or sent
|
||||||
|
- should prevent accidental duplicate sends for the same update unless the developer explicitly confirms a resend
|
||||||
|
- should use the same concise email template as digest emails, focused on the selected update
|
||||||
|
|
||||||
|
Email delivery should use the existing email infrastructure. Do not introduce a new provider.
|
||||||
|
|
||||||
|
Email preferences are out of scope for the first email task. A later task can add user or organization notification preferences.
|
||||||
|
|
||||||
|
## API Expectations
|
||||||
|
|
||||||
|
Initial user-facing API:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
GET /api/release-updates
|
||||||
|
GET /api/release-updates/unread
|
||||||
|
POST /api/release-updates/{id}/read
|
||||||
|
POST /api/release-updates/read-all
|
||||||
|
```
|
||||||
|
|
||||||
|
Initial developer API:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
GET /api/developer/release-updates
|
||||||
|
POST /api/developer/release-updates
|
||||||
|
GET /api/developer/release-updates/{id}
|
||||||
|
PUT /api/developer/release-updates/{id}
|
||||||
|
POST /api/developer/release-updates/{id}/publish
|
||||||
|
POST /api/developer/release-updates/{id}/archive
|
||||||
|
POST /api/developer/release-updates/{id}/send-email
|
||||||
|
GET /api/developer/release-commits
|
||||||
|
POST /api/developer/release-commits/import
|
||||||
|
POST /api/developer/release-commits/{sha}/link
|
||||||
|
POST /api/developer/release-commits/{sha}/unlink
|
||||||
|
POST /api/developer/release-commits/{sha}/internal-only
|
||||||
|
POST /api/developer/release-commits/{sha}/ignore
|
||||||
|
```
|
||||||
|
|
||||||
|
Backend contract changes require OpenAPI regeneration while the backend is running.
|
||||||
|
|
||||||
|
## Localization
|
||||||
|
|
||||||
|
User-facing release communication UI must be available in English and French.
|
||||||
|
|
||||||
|
Developer-only back-office labels should also be localized when they appear in the app shell.
|
||||||
|
|
||||||
|
## Out Of Scope For V1
|
||||||
|
|
||||||
|
- Fully automatic update generation from Git commits
|
||||||
|
- Reading commits from the deployed app filesystem
|
||||||
|
- Public release notes pages
|
||||||
|
- Per-user or per-organization email preferences
|
||||||
|
- Scheduled publishing
|
||||||
|
- Targeting individual users or individual organizations
|
||||||
|
- Rich content blocks beyond plain text or basic markdown-style text
|
||||||
|
- Attachments or screenshots
|
||||||
|
- Multiple release streams
|
||||||
|
- Requiring semantic version numbers
|
||||||
|
- Linking one commit to multiple update entries
|
||||||
|
|
||||||
|
## Done When
|
||||||
|
|
||||||
|
- [ ] Developers can create and publish curated release updates.
|
||||||
|
- [ ] Authenticated users can see visible published updates.
|
||||||
|
- [ ] Users can tell which updates are unread.
|
||||||
|
- [ ] Users can mark updates read individually and in bulk.
|
||||||
|
- [ ] The app shell surfaces unread update counts.
|
||||||
|
- [ ] Developers can import commits into a reconciliation list.
|
||||||
|
- [ ] Developers can link commits to update entries.
|
||||||
|
- [ ] Developers can mark commits internal-only or ignored.
|
||||||
|
- [ ] User-facing update views do not expose internal commit details.
|
||||||
|
- [ ] Optional email digest and manual push-email behavior are documented and implemented in a separate task.
|
||||||
|
- [ ] Backend build and tests pass.
|
||||||
|
- [ ] Frontend build passes.
|
||||||
|
- [ ] OpenAPI is updated after backend contracts are implemented.
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Task: Backend release update foundation
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add the backend foundation for curated release update entries and per-user read state.
|
||||||
|
|
||||||
|
## Feature Spec
|
||||||
|
|
||||||
|
- `docs/FEATURES/release-communications.md`
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Add a new FastEndpoints module under `backend/src/Socialize.Api/Modules/ReleaseCommunications`.
|
||||||
|
- Add release update data entities and EF Core model configuration.
|
||||||
|
- Add per-user release update read receipts.
|
||||||
|
- Add enum/value support for:
|
||||||
|
- category: `Feature`, `Improvement`, `Fix`, `BreakingChange`
|
||||||
|
- importance: `Normal`, `Important`
|
||||||
|
- audience: `Everyone`, `OrganizationOwners`, `Developers`
|
||||||
|
- status: `Draft`, `Published`, `Archived`
|
||||||
|
- Add `DbSet` entries and module configuration to `AppDbContext`.
|
||||||
|
- Add current-user API endpoints:
|
||||||
|
- list visible published release updates
|
||||||
|
- get unread visible release updates
|
||||||
|
- mark one release update as read
|
||||||
|
- mark all visible release updates as read
|
||||||
|
- Add developer API endpoints:
|
||||||
|
- list all release updates
|
||||||
|
- create draft release update
|
||||||
|
- get release update detail
|
||||||
|
- update draft release update
|
||||||
|
- publish release update
|
||||||
|
- archive release update
|
||||||
|
- Enforce access rules:
|
||||||
|
- authenticated users can read only visible published updates
|
||||||
|
- only `Developer` users can manage update entries
|
||||||
|
- organization-owner audience only appears to users who own at least one organization
|
||||||
|
- developer audience only appears to `Developer` users
|
||||||
|
- Keep commit import and email digest out of this task.
|
||||||
|
|
||||||
|
## Likely Files
|
||||||
|
|
||||||
|
- `backend/src/Socialize.Api/Data/AppDbContext.cs`
|
||||||
|
- `backend/src/Socialize.Api/Modules/ReleaseCommunications/**`
|
||||||
|
- `backend/tests/Socialize.Tests/**`
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Treat release communications as global SaaS operator data, not workspace-owned workflow data.
|
||||||
|
- Use FastEndpoints handlers and keep request/response records near handlers unless local module patterns suggest otherwise.
|
||||||
|
- Use FluentValidation for non-trivial input.
|
||||||
|
- Do not expose draft entries to non-developer users.
|
||||||
|
- Do not expose commit metadata in user-facing DTOs.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build backend/Socialize.slnx
|
||||||
|
dotnet test backend/Socialize.slnx
|
||||||
|
```
|
||||||
|
|
||||||
|
## Done When
|
||||||
|
|
||||||
|
- [x] Developers can create draft release update entries.
|
||||||
|
- [x] Developers can publish and archive release updates.
|
||||||
|
- [x] Authenticated users can list visible published updates.
|
||||||
|
- [x] Audience filtering is enforced.
|
||||||
|
- [x] Users can mark one update read.
|
||||||
|
- [x] Users can mark all visible updates read.
|
||||||
|
- [x] Unread queries only count visible published updates.
|
||||||
|
- [x] Backend tests cover access rules and read state.
|
||||||
54
docs/TASKS/release-communications/002-frontend-whats-new.md
Normal file
54
docs/TASKS/release-communications/002-frontend-whats-new.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# Task: Frontend What’s New experience
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add the user-facing What’s New experience for published release updates and unread state.
|
||||||
|
|
||||||
|
## Feature Spec
|
||||||
|
|
||||||
|
- `docs/FEATURES/release-communications.md`
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Add feature-owned frontend code under `frontend/src/features/release-communications/`.
|
||||||
|
- Add `/app/updates`.
|
||||||
|
- Add an app shell entry or badge for unread release updates.
|
||||||
|
- Fetch visible published release updates from the backend.
|
||||||
|
- Show unread state for update entries.
|
||||||
|
- Mark an update as read when opened.
|
||||||
|
- Add a mark-all-read action.
|
||||||
|
- Optionally show a non-blocking login-time What’s New panel when unread updates exist.
|
||||||
|
- Add English and French locale strings.
|
||||||
|
- Keep developer authoring UI, commit reconciliation, and email digest out of this task.
|
||||||
|
|
||||||
|
## Likely Files
|
||||||
|
|
||||||
|
- `frontend/src/router/router.js`
|
||||||
|
- `frontend/src/layouts/main/**`
|
||||||
|
- `frontend/src/features/release-communications/**`
|
||||||
|
- `frontend/src/locales/en.json`
|
||||||
|
- `frontend/src/locales/fr.json`
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The user-facing update feed must be curated and should not show raw commit SHAs, commit subjects, branch names, or internal-only work.
|
||||||
|
- Keep the UI compact and app-like. This is an operational app surface, not a marketing release notes page.
|
||||||
|
- Use the shared Axios API client in `frontend/src/plugins/api.js`.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Done When
|
||||||
|
|
||||||
|
- [x] Authenticated users can open `/app/updates`.
|
||||||
|
- [x] The app shell shows unread update count.
|
||||||
|
- [x] Published visible updates are listed newest first.
|
||||||
|
- [x] Unread updates are visually distinct.
|
||||||
|
- [x] Opening an update marks it read.
|
||||||
|
- [x] Users can mark all visible updates read.
|
||||||
|
- [x] UI strings exist in English and French.
|
||||||
|
- [x] Frontend build passes.
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# Task: Developer commit reconciliation
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add the developer back-office workflow for importing shipped commits and matching them to curated release update entries.
|
||||||
|
|
||||||
|
## Feature Spec
|
||||||
|
|
||||||
|
- `docs/FEATURES/release-communications.md`
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Add release commit persistence and EF Core model configuration.
|
||||||
|
- Add enum/value support for communication status:
|
||||||
|
- `Unreviewed`
|
||||||
|
- `Linked`
|
||||||
|
- `InternalOnly`
|
||||||
|
- `Ignored`
|
||||||
|
- Add developer API endpoints:
|
||||||
|
- list imported commits
|
||||||
|
- import commits for a bounded range
|
||||||
|
- link a commit to a release update
|
||||||
|
- unlink a commit from a release update
|
||||||
|
- mark a commit internal-only
|
||||||
|
- mark a commit ignored
|
||||||
|
- Add developer-only frontend screens:
|
||||||
|
- `/app/developer/release-commits`
|
||||||
|
- linked commits on `/app/developer/updates/:id`
|
||||||
|
- Support filters for:
|
||||||
|
- communication status
|
||||||
|
- linked update
|
||||||
|
- author
|
||||||
|
- date range
|
||||||
|
- text search by subject or SHA
|
||||||
|
- Show an unreviewed commit count.
|
||||||
|
- Keep user-facing update views free of commit metadata.
|
||||||
|
- Keep automatic CI deployment integration out of this task.
|
||||||
|
|
||||||
|
## Likely Files
|
||||||
|
|
||||||
|
- `backend/src/Socialize.Api/Data/AppDbContext.cs`
|
||||||
|
- `backend/src/Socialize.Api/Modules/ReleaseCommunications/**`
|
||||||
|
- `frontend/src/router/router.js`
|
||||||
|
- `frontend/src/layouts/main/**`
|
||||||
|
- `frontend/src/features/release-communications/**`
|
||||||
|
- `frontend/src/locales/en.json`
|
||||||
|
- `frontend/src/locales/fr.json`
|
||||||
|
- `backend/tests/Socialize.Tests/**`
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Commit import must be idempotent by SHA.
|
||||||
|
- A commit can be linked to at most one release update in v1.
|
||||||
|
- A release update can have many linked commits.
|
||||||
|
- Imported commits default to `Unreviewed`.
|
||||||
|
- Import must use either a submitted commit payload or configured repository connection settings. Do not discover or read a local `.git` directory from the deployed app filesystem.
|
||||||
|
- Repository URL and access credentials must come from configuration/secrets.
|
||||||
|
- Do not generate user-facing update entries automatically from commits.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build backend/Socialize.slnx
|
||||||
|
dotnet test backend/Socialize.slnx
|
||||||
|
cd frontend
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Done When
|
||||||
|
|
||||||
|
- [x] Developers can import commits idempotently.
|
||||||
|
- [x] Developers can list and filter imported commits.
|
||||||
|
- [x] Developers can link commits to release updates.
|
||||||
|
- [x] Developers can unlink commits.
|
||||||
|
- [x] Developers can mark commits internal-only.
|
||||||
|
- [x] Developers can mark commits ignored.
|
||||||
|
- [x] Release update detail shows linked commits to developers.
|
||||||
|
- [x] Unreviewed commit count is visible to developers.
|
||||||
|
- [x] Non-developer users cannot access commit reconciliation APIs or UI.
|
||||||
|
- [x] User-facing update views do not expose commit metadata.
|
||||||
70
docs/TASKS/release-communications/004-email-digest.md
Normal file
70
docs/TASKS/release-communications/004-email-digest.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# Task: Release update email digest
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add optional daily email digests for inactive users with unread release updates, plus a developer-operated manual email push for important published updates.
|
||||||
|
|
||||||
|
## Feature Spec
|
||||||
|
|
||||||
|
- `docs/FEATURES/release-communications.md`
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Add configuration to enable or disable release update email digests.
|
||||||
|
- Add persistence needed to rate-limit digest sends per user.
|
||||||
|
- Send at most one digest per user per day.
|
||||||
|
- Send only when the user has unread visible published release updates.
|
||||||
|
- Send only when the user has not logged in or opened the app in at least 24 hours.
|
||||||
|
- Initially target organization owners.
|
||||||
|
- Use the existing email infrastructure.
|
||||||
|
- Add a developer-only API endpoint to send an email announcement for a selected published release update.
|
||||||
|
- Add a developer-only back-office button for sending the selected update by email.
|
||||||
|
- Require confirmation before sending a manual push email.
|
||||||
|
- Support a "send to me only" test mode.
|
||||||
|
- Record manual push email metadata:
|
||||||
|
- sent by user id
|
||||||
|
- sent timestamp
|
||||||
|
- selected audience
|
||||||
|
- recipient count
|
||||||
|
- Prevent accidental duplicate push sends unless the developer explicitly confirms a resend.
|
||||||
|
- Keep user or organization email preferences out of this task.
|
||||||
|
|
||||||
|
## Likely Files
|
||||||
|
|
||||||
|
- `backend/src/Socialize.Api/Infrastructure/Emailer/**`
|
||||||
|
- `backend/src/Socialize.Api/Modules/ReleaseCommunications/**`
|
||||||
|
- `backend/src/Socialize.Api/Modules/Identity/**`
|
||||||
|
- `backend/src/Socialize.Api/Modules/Organizations/**`
|
||||||
|
- `frontend/src/features/release-communications/**`
|
||||||
|
- `frontend/src/locales/en.json`
|
||||||
|
- `frontend/src/locales/fr.json`
|
||||||
|
- `backend/tests/Socialize.Tests/**`
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- This task may require tracking a user's last app activity timestamp if login timestamp alone is not enough.
|
||||||
|
- Keep email copy concise and product-specific.
|
||||||
|
- The digest should link back to the app's What’s New page.
|
||||||
|
- Manual push emails should link directly to the selected update when possible.
|
||||||
|
- Do not introduce a new email provider.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build backend/Socialize.slnx
|
||||||
|
dotnet test backend/Socialize.slnx
|
||||||
|
```
|
||||||
|
|
||||||
|
## Done When
|
||||||
|
|
||||||
|
- [x] Digest delivery is disabled unless explicitly configured.
|
||||||
|
- [x] Eligible organization owners receive at most one digest per day.
|
||||||
|
- [x] Digests are sent only when unread visible release updates exist.
|
||||||
|
- [x] Active users are not emailed.
|
||||||
|
- [x] Developers can manually send a published update email from the back office.
|
||||||
|
- [x] Developers can send a manual update email to themselves as a test.
|
||||||
|
- [x] Manual push sends require confirmation.
|
||||||
|
- [x] Manual push sends record sender, timestamp, audience, and recipient count.
|
||||||
|
- [x] Duplicate manual sends require explicit resend confirmation.
|
||||||
|
- [x] Email delivery uses existing email infrastructure.
|
||||||
|
- [x] Backend tests cover eligibility and rate limiting.
|
||||||
969
frontend/src/api/schema.d.ts
vendored
969
frontend/src/api/schema.d.ts
vendored
@@ -100,6 +100,246 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/developer/release-updates/{id}/archive": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post: operations["SocializeApiModulesReleaseCommunicationsHandlersArchiveDeveloperReleaseUpdateHandler"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/developer/release-updates": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["SocializeApiModulesReleaseCommunicationsHandlersListDeveloperReleaseUpdatesHandler"];
|
||||||
|
put?: never;
|
||||||
|
post: operations["SocializeApiModulesReleaseCommunicationsHandlersCreateDeveloperReleaseUpdateHandler"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/developer/release-updates/{id}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["SocializeApiModulesReleaseCommunicationsHandlersGetDeveloperReleaseUpdateHandler"];
|
||||||
|
put: operations["SocializeApiModulesReleaseCommunicationsHandlersUpdateDeveloperReleaseUpdateHandler"];
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/release-updates/unread": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["SocializeApiModulesReleaseCommunicationsHandlersGetUnreadReleaseUpdatesHandler"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/developer/release-commits/import": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post: operations["SocializeApiModulesReleaseCommunicationsHandlersImportDeveloperReleaseCommitsHandler"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/developer/release-commits": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["SocializeApiModulesReleaseCommunicationsHandlersListDeveloperReleaseCommitsHandler"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/release-updates": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["SocializeApiModulesReleaseCommunicationsHandlersListReleaseUpdatesHandler"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/release-updates/read-all": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post: operations["SocializeApiModulesReleaseCommunicationsHandlersMarkAllReleaseUpdatesReadHandler"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/release-updates/{id}/read": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post: operations["SocializeApiModulesReleaseCommunicationsHandlersMarkReleaseUpdateReadHandler"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/developer/release-updates/{id}/publish": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post: operations["SocializeApiModulesReleaseCommunicationsHandlersPublishDeveloperReleaseUpdateHandler"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/developer/release-updates/{id}/send-email": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post: operations["SocializeApiModulesReleaseCommunicationsHandlersSendDeveloperReleaseUpdateEmailHandler"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/developer/release-commits/{sha}/link": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post: operations["SocializeApiModulesReleaseCommunicationsHandlersLinkDeveloperReleaseCommitHandler"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/developer/release-commits/{sha}/unlink": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post: operations["SocializeApiModulesReleaseCommunicationsHandlersUnlinkDeveloperReleaseCommitHandler"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/developer/release-commits/{sha}/internal-only": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post: operations["SocializeApiModulesReleaseCommunicationsHandlersMarkDeveloperReleaseCommitInternalOnlyHandler"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/developer/release-commits/{sha}/ignore": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post: operations["SocializeApiModulesReleaseCommunicationsHandlersIgnoreDeveloperReleaseCommitHandler"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/organizations/{organizationId}/members": {
|
"/api/organizations/{organizationId}/members": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1227,6 +1467,131 @@ export interface components {
|
|||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
requiredApproverCount?: number;
|
requiredApproverCount?: number;
|
||||||
};
|
};
|
||||||
|
SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateDto: {
|
||||||
|
/** Format: guid */
|
||||||
|
id?: string;
|
||||||
|
title?: string;
|
||||||
|
summary?: string;
|
||||||
|
body?: string | null;
|
||||||
|
category?: string;
|
||||||
|
importance?: string;
|
||||||
|
audience?: string;
|
||||||
|
status?: string;
|
||||||
|
deploymentLabel?: string | null;
|
||||||
|
buildVersion?: string | null;
|
||||||
|
commitRange?: string | null;
|
||||||
|
/** Format: date-time */
|
||||||
|
createdAt?: string;
|
||||||
|
/** Format: date-time */
|
||||||
|
updatedAt?: string;
|
||||||
|
/** Format: date-time */
|
||||||
|
publishedAt?: string | null;
|
||||||
|
/** Format: date-time */
|
||||||
|
archivedAt?: string | null;
|
||||||
|
/** Format: guid */
|
||||||
|
manualEmailSentByUserId?: string | null;
|
||||||
|
/** Format: date-time */
|
||||||
|
manualEmailSentAt?: string | null;
|
||||||
|
manualEmailAudience?: string | null;
|
||||||
|
/** Format: int32 */
|
||||||
|
manualEmailRecipientCount?: number | null;
|
||||||
|
isRead?: boolean;
|
||||||
|
};
|
||||||
|
SocializeApiModulesReleaseCommunicationsHandlersCreateDeveloperReleaseUpdateRequest: {
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
body?: string | null;
|
||||||
|
category: string;
|
||||||
|
importance: string;
|
||||||
|
audience: string;
|
||||||
|
deploymentLabel?: string | null;
|
||||||
|
buildVersion?: string | null;
|
||||||
|
commitRange?: string | null;
|
||||||
|
};
|
||||||
|
SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateUnreadSummaryDto: {
|
||||||
|
/** Format: int32 */
|
||||||
|
unreadCount?: number;
|
||||||
|
/** Format: int32 */
|
||||||
|
importantUnreadCount?: number;
|
||||||
|
updates?: components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateDto"][];
|
||||||
|
};
|
||||||
|
SocializeApiModulesReleaseCommunicationsContractsReleaseCommitImportResultDto: {
|
||||||
|
/** Format: int32 */
|
||||||
|
importedCount?: number;
|
||||||
|
/** Format: int32 */
|
||||||
|
updatedCount?: number;
|
||||||
|
/** Format: int32 */
|
||||||
|
skippedCount?: number;
|
||||||
|
commits?: components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseCommitDto"][];
|
||||||
|
};
|
||||||
|
SocializeApiModulesReleaseCommunicationsContractsReleaseCommitDto: {
|
||||||
|
sha?: string;
|
||||||
|
shortSha?: string;
|
||||||
|
subject?: string;
|
||||||
|
authorName?: string | null;
|
||||||
|
authorEmail?: string | null;
|
||||||
|
/** Format: date-time */
|
||||||
|
authoredAt?: string | null;
|
||||||
|
/** Format: date-time */
|
||||||
|
committedAt?: string | null;
|
||||||
|
sourceBranch?: string | null;
|
||||||
|
deploymentLabel?: string | null;
|
||||||
|
externalUrl?: string | null;
|
||||||
|
communicationStatus?: string;
|
||||||
|
/** Format: guid */
|
||||||
|
releaseUpdateId?: string | null;
|
||||||
|
/** Format: date-time */
|
||||||
|
importedAt?: string;
|
||||||
|
/** Format: date-time */
|
||||||
|
updatedAt?: string;
|
||||||
|
};
|
||||||
|
SocializeApiModulesReleaseCommunicationsHandlersImportDeveloperReleaseCommitsRequest: {
|
||||||
|
sinceSha?: string | null;
|
||||||
|
untilSha?: string | null;
|
||||||
|
sourceBranch?: string | null;
|
||||||
|
deploymentLabel?: string | null;
|
||||||
|
commits?: components["schemas"]["SocializeApiModulesReleaseCommunicationsHandlersImportDeveloperReleaseCommitDto"][] | null;
|
||||||
|
};
|
||||||
|
SocializeApiModulesReleaseCommunicationsHandlersImportDeveloperReleaseCommitDto: {
|
||||||
|
sha?: string;
|
||||||
|
shortSha?: string | null;
|
||||||
|
subject?: string;
|
||||||
|
authorName?: string | null;
|
||||||
|
authorEmail?: string | null;
|
||||||
|
/** Format: date-time */
|
||||||
|
authoredAt?: string | null;
|
||||||
|
/** Format: date-time */
|
||||||
|
committedAt?: string | null;
|
||||||
|
sourceBranch?: string | null;
|
||||||
|
deploymentLabel?: string | null;
|
||||||
|
externalUrl?: string | null;
|
||||||
|
};
|
||||||
|
SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateEmailSendResultDto: {
|
||||||
|
/** Format: int32 */
|
||||||
|
recipientCount?: number;
|
||||||
|
/** Format: date-time */
|
||||||
|
sentAt?: string;
|
||||||
|
testMode?: boolean;
|
||||||
|
};
|
||||||
|
SocializeApiModulesReleaseCommunicationsHandlersSendDeveloperReleaseUpdateEmailRequest: {
|
||||||
|
testMode?: boolean;
|
||||||
|
confirmResend?: boolean;
|
||||||
|
};
|
||||||
|
SocializeApiModulesReleaseCommunicationsHandlersLinkDeveloperReleaseCommitRequest: {
|
||||||
|
/** Format: guid */
|
||||||
|
releaseUpdateId?: string;
|
||||||
|
};
|
||||||
|
SocializeApiModulesReleaseCommunicationsHandlersUpdateDeveloperReleaseUpdateRequest: {
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
body?: string | null;
|
||||||
|
category: string;
|
||||||
|
importance: string;
|
||||||
|
audience: string;
|
||||||
|
deploymentLabel?: string | null;
|
||||||
|
buildVersion?: string | null;
|
||||||
|
commitRange?: string | null;
|
||||||
|
};
|
||||||
SocializeApiModulesOrganizationsHandlersOrganizationMemberDto: {
|
SocializeApiModulesOrganizationsHandlersOrganizationMemberDto: {
|
||||||
/** Format: guid */
|
/** Format: guid */
|
||||||
userId?: string;
|
userId?: string;
|
||||||
@@ -2277,6 +2642,610 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
SocializeApiModulesReleaseCommunicationsHandlersArchiveDeveloperReleaseUpdateHandler: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Success */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Unauthorized */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Forbidden */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
SocializeApiModulesReleaseCommunicationsHandlersListDeveloperReleaseUpdatesHandler: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Success */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateDto"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Unauthorized */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Forbidden */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
SocializeApiModulesReleaseCommunicationsHandlersCreateDeveloperReleaseUpdateHandler: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsHandlersCreateDeveloperReleaseUpdateRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Success */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Bad Request */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/problem+json": components["schemas"]["FastEndpointsErrorResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Unauthorized */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Forbidden */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
SocializeApiModulesReleaseCommunicationsHandlersGetDeveloperReleaseUpdateHandler: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Success */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Unauthorized */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Forbidden */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
SocializeApiModulesReleaseCommunicationsHandlersUpdateDeveloperReleaseUpdateHandler: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsHandlersUpdateDeveloperReleaseUpdateRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Success */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Bad Request */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/problem+json": components["schemas"]["FastEndpointsErrorResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Unauthorized */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Forbidden */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
SocializeApiModulesReleaseCommunicationsHandlersGetUnreadReleaseUpdatesHandler: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Success */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateUnreadSummaryDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Unauthorized */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
SocializeApiModulesReleaseCommunicationsHandlersImportDeveloperReleaseCommitsHandler: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsHandlersImportDeveloperReleaseCommitsRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Success */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseCommitImportResultDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Unauthorized */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Forbidden */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
SocializeApiModulesReleaseCommunicationsHandlersListDeveloperReleaseCommitsHandler: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Success */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseCommitDto"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Unauthorized */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Forbidden */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
SocializeApiModulesReleaseCommunicationsHandlersListReleaseUpdatesHandler: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Success */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateDto"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Unauthorized */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
SocializeApiModulesReleaseCommunicationsHandlersMarkAllReleaseUpdatesReadHandler: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description No Content */
|
||||||
|
204: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Unauthorized */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
SocializeApiModulesReleaseCommunicationsHandlersMarkReleaseUpdateReadHandler: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description No Content */
|
||||||
|
204: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Unauthorized */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
SocializeApiModulesReleaseCommunicationsHandlersPublishDeveloperReleaseUpdateHandler: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Success */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Unauthorized */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Forbidden */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
SocializeApiModulesReleaseCommunicationsHandlersSendDeveloperReleaseUpdateEmailHandler: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsHandlersSendDeveloperReleaseUpdateEmailRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Success */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateEmailSendResultDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Unauthorized */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Forbidden */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
SocializeApiModulesReleaseCommunicationsHandlersLinkDeveloperReleaseCommitHandler: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
sha: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsHandlersLinkDeveloperReleaseCommitRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Success */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseCommitDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Unauthorized */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Forbidden */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
SocializeApiModulesReleaseCommunicationsHandlersUnlinkDeveloperReleaseCommitHandler: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
sha: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Success */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseCommitDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Unauthorized */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Forbidden */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
SocializeApiModulesReleaseCommunicationsHandlersMarkDeveloperReleaseCommitInternalOnlyHandler: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
sha: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Success */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseCommitDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Unauthorized */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Forbidden */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
SocializeApiModulesReleaseCommunicationsHandlersIgnoreDeveloperReleaseCommitHandler: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
sha: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Success */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseCommitDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Unauthorized */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Forbidden */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
SocializeApiModulesOrganizationsHandlersAddOrganizationMemberHandler: {
|
SocializeApiModulesOrganizationsHandlersAddOrganizationMemberHandler: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { useClient } from '@/plugins/api.js';
|
||||||
|
|
||||||
|
const DEFAULT_COMMIT_FILTERS = Object.freeze({
|
||||||
|
status: '',
|
||||||
|
updateId: '',
|
||||||
|
author: '',
|
||||||
|
search: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const RELEASE_UPDATE_CATEGORIES = ['Feature', 'Improvement', 'Fix', 'Breaking Change'];
|
||||||
|
export const RELEASE_UPDATE_IMPORTANCE = ['Normal', 'Important'];
|
||||||
|
export const RELEASE_UPDATE_AUDIENCES = ['Everyone', 'OrganizationOwners', 'Developers'];
|
||||||
|
export const RELEASE_COMMIT_STATUSES = ['Unreviewed', 'Linked', 'InternalOnly', 'Ignored'];
|
||||||
|
|
||||||
|
export const useReleaseCommunicationsStore = defineStore('release-communications', () => {
|
||||||
|
const client = useClient();
|
||||||
|
const updates = ref([]);
|
||||||
|
const unreadSummary = ref({ unreadCount: 0, importantUnreadCount: 0, updates: [] });
|
||||||
|
const developerUpdates = ref([]);
|
||||||
|
const selectedUpdate = ref(null);
|
||||||
|
const commits = ref([]);
|
||||||
|
const commitFilters = ref({ ...DEFAULT_COMMIT_FILTERS });
|
||||||
|
const isLoading = ref(false);
|
||||||
|
const isSaving = ref(false);
|
||||||
|
const isSendingEmail = ref(false);
|
||||||
|
const isImporting = ref(false);
|
||||||
|
const error = ref(null);
|
||||||
|
|
||||||
|
const unreadCount = computed(() => unreadSummary.value?.unreadCount ?? 0);
|
||||||
|
const importantUnreadCount = computed(() => unreadSummary.value?.importantUnreadCount ?? 0);
|
||||||
|
const unreviewedCommitCount = computed(() =>
|
||||||
|
commits.value.filter(commit => commit.communicationStatus === 'Unreviewed').length
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredCommits = computed(() => {
|
||||||
|
const query = commitFilters.value.search.trim().toLowerCase();
|
||||||
|
const author = commitFilters.value.author.trim().toLowerCase();
|
||||||
|
|
||||||
|
return commits.value.filter(commit => {
|
||||||
|
if (commitFilters.value.status && commit.communicationStatus !== commitFilters.value.status) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (commitFilters.value.updateId && commit.releaseUpdateId !== commitFilters.value.updateId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (author) {
|
||||||
|
const authorText = `${commit.authorName ?? ''} ${commit.authorEmail ?? ''}`.toLowerCase();
|
||||||
|
if (!authorText.includes(author)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
const haystack = [
|
||||||
|
commit.sha,
|
||||||
|
commit.shortSha,
|
||||||
|
commit.subject,
|
||||||
|
commit.authorName,
|
||||||
|
commit.authorEmail,
|
||||||
|
commit.deploymentLabel,
|
||||||
|
commit.sourceBranch,
|
||||||
|
].filter(Boolean).join(' ').toLowerCase();
|
||||||
|
if (!haystack.includes(query)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadUserUpdates() {
|
||||||
|
isLoading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const [updatesResponse, unreadResponse] = await Promise.all([
|
||||||
|
client.get('/api/release-updates'),
|
||||||
|
client.get('/api/release-updates/unread'),
|
||||||
|
]);
|
||||||
|
updates.value = updatesResponse.data ?? [];
|
||||||
|
unreadSummary.value = unreadResponse.data ?? { unreadCount: 0, importantUnreadCount: 0, updates: [] };
|
||||||
|
} catch (loadError) {
|
||||||
|
console.error('Failed to load release updates:', loadError);
|
||||||
|
error.value = 'releaseCommunications.errors.loadFailed';
|
||||||
|
throw loadError;
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markRead(id) {
|
||||||
|
await client.post(`/api/release-updates/${id}/read`);
|
||||||
|
updates.value = updates.value.map(update => update.id === id ? { ...update, isRead: true } : update);
|
||||||
|
await loadUnreadSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markAllRead() {
|
||||||
|
await client.post('/api/release-updates/read-all');
|
||||||
|
updates.value = updates.value.map(update => ({ ...update, isRead: true }));
|
||||||
|
await loadUnreadSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUnreadSummary() {
|
||||||
|
const response = await client.get('/api/release-updates/unread');
|
||||||
|
unreadSummary.value = response.data ?? { unreadCount: 0, importantUnreadCount: 0, updates: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDeveloperUpdates() {
|
||||||
|
isLoading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const response = await client.get('/api/developer/release-updates');
|
||||||
|
developerUpdates.value = response.data ?? [];
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDeveloperUpdate(id) {
|
||||||
|
isLoading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await client.get(`/api/developer/release-updates/${id}`);
|
||||||
|
selectedUpdate.value = response.data;
|
||||||
|
return selectedUpdate.value;
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveDeveloperUpdate(payload, id = null) {
|
||||||
|
isSaving.value = true;
|
||||||
|
try {
|
||||||
|
const response = id
|
||||||
|
? await client.put(`/api/developer/release-updates/${id}`, payload)
|
||||||
|
: await client.post('/api/developer/release-updates', payload);
|
||||||
|
selectedUpdate.value = response.data;
|
||||||
|
await loadDeveloperUpdates();
|
||||||
|
return response.data;
|
||||||
|
} finally {
|
||||||
|
isSaving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publishUpdate(id) {
|
||||||
|
const response = await client.post(`/api/developer/release-updates/${id}/publish`);
|
||||||
|
selectedUpdate.value = response.data;
|
||||||
|
await loadDeveloperUpdates();
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function archiveUpdate(id) {
|
||||||
|
const response = await client.post(`/api/developer/release-updates/${id}/archive`);
|
||||||
|
selectedUpdate.value = response.data;
|
||||||
|
await loadDeveloperUpdates();
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendUpdateEmail(id, payload) {
|
||||||
|
isSendingEmail.value = true;
|
||||||
|
try {
|
||||||
|
return (await client.post(`/api/developer/release-updates/${id}/send-email`, payload)).data;
|
||||||
|
} finally {
|
||||||
|
isSendingEmail.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCommits() {
|
||||||
|
const response = await client.get('/api/developer/release-commits');
|
||||||
|
commits.value = response.data ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importCommits(payload) {
|
||||||
|
isImporting.value = true;
|
||||||
|
try {
|
||||||
|
const response = await client.post('/api/developer/release-commits/import', payload);
|
||||||
|
await loadCommits();
|
||||||
|
return response.data;
|
||||||
|
} finally {
|
||||||
|
isImporting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function linkCommit(sha, releaseUpdateId) {
|
||||||
|
await client.post(`/api/developer/release-commits/${sha}/link`, { releaseUpdateId });
|
||||||
|
await loadCommits();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unlinkCommit(sha) {
|
||||||
|
await client.post(`/api/developer/release-commits/${sha}/unlink`);
|
||||||
|
await loadCommits();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markCommitInternalOnly(sha) {
|
||||||
|
await client.post(`/api/developer/release-commits/${sha}/internal-only`);
|
||||||
|
await loadCommits();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ignoreCommit(sha) {
|
||||||
|
await client.post(`/api/developer/release-commits/${sha}/ignore`);
|
||||||
|
await loadCommits();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetCommitFilters() {
|
||||||
|
commitFilters.value = { ...DEFAULT_COMMIT_FILTERS };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
updates,
|
||||||
|
unreadSummary,
|
||||||
|
developerUpdates,
|
||||||
|
selectedUpdate,
|
||||||
|
commits,
|
||||||
|
commitFilters,
|
||||||
|
filteredCommits,
|
||||||
|
unreadCount,
|
||||||
|
importantUnreadCount,
|
||||||
|
unreviewedCommitCount,
|
||||||
|
isLoading,
|
||||||
|
isSaving,
|
||||||
|
isSendingEmail,
|
||||||
|
isImporting,
|
||||||
|
error,
|
||||||
|
loadUserUpdates,
|
||||||
|
loadUnreadSummary,
|
||||||
|
markRead,
|
||||||
|
markAllRead,
|
||||||
|
loadDeveloperUpdates,
|
||||||
|
loadDeveloperUpdate,
|
||||||
|
saveDeveloperUpdate,
|
||||||
|
publishUpdate,
|
||||||
|
archiveUpdate,
|
||||||
|
sendUpdateEmail,
|
||||||
|
loadCommits,
|
||||||
|
importCommits,
|
||||||
|
linkCommit,
|
||||||
|
unlinkCommit,
|
||||||
|
markCommitInternalOnly,
|
||||||
|
ignoreCommit,
|
||||||
|
resetCommitFilters,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import {
|
||||||
|
RELEASE_COMMIT_STATUSES,
|
||||||
|
useReleaseCommunicationsStore,
|
||||||
|
} from '@/features/release-communications/stores/releaseCommunicationsStore.js';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const store = useReleaseCommunicationsStore();
|
||||||
|
const importJson = ref('[]');
|
||||||
|
|
||||||
|
const updateOptions = computed(() =>
|
||||||
|
store.developerUpdates.map(update => ({ title: update.title, value: update.id }))
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([store.loadDeveloperUpdates(), store.loadCommits()]);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function importPayload() {
|
||||||
|
const commits = JSON.parse(importJson.value);
|
||||||
|
await store.importCommits({ commits });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value) {
|
||||||
|
return value ? new Date(value).toLocaleString() : '';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="commits-page">
|
||||||
|
<header class="page-header">
|
||||||
|
<div>
|
||||||
|
<div class="eyebrow">{{ t('releaseCommunications.commits.eyebrow') }}</div>
|
||||||
|
<h1>{{ t('releaseCommunications.commits.title') }}</h1>
|
||||||
|
<p>{{ t('releaseCommunications.commits.description') }}</p>
|
||||||
|
</div>
|
||||||
|
<strong>{{ store.unreviewedCommitCount }} {{ t('releaseCommunications.commits.unreviewed') }}</strong>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="import-panel">
|
||||||
|
<v-textarea
|
||||||
|
v-model="importJson"
|
||||||
|
:label="t('releaseCommunications.commits.importJson')"
|
||||||
|
rows="5"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
<v-btn :loading="store.isImporting" @click="importPayload">{{ t('releaseCommunications.commits.import') }}</v-btn>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="filter-panel">
|
||||||
|
<v-text-field v-model="store.commitFilters.search" :label="t('releaseCommunications.commits.search')" density="compact" variant="outlined" hide-details />
|
||||||
|
<v-select v-model="store.commitFilters.status" :items="RELEASE_COMMIT_STATUSES" :label="t('releaseCommunications.commits.status')" density="compact" variant="outlined" hide-details clearable />
|
||||||
|
<v-select v-model="store.commitFilters.updateId" :items="updateOptions" :label="t('releaseCommunications.commits.linkedUpdate')" density="compact" variant="outlined" hide-details clearable />
|
||||||
|
<v-text-field v-model="store.commitFilters.author" :label="t('releaseCommunications.commits.author')" density="compact" variant="outlined" hide-details clearable />
|
||||||
|
<v-btn variant="outlined" @click="store.resetCommitFilters">{{ t('releaseCommunications.commits.clear') }}</v-btn>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="commit-table">
|
||||||
|
<article
|
||||||
|
v-for="commit in store.filteredCommits"
|
||||||
|
:key="commit.sha"
|
||||||
|
class="commit-row"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<code>{{ commit.shortSha }}</code>
|
||||||
|
<strong>{{ commit.subject }}</strong>
|
||||||
|
<small>{{ commit.authorName }} / {{ formatDate(commit.committedAt) }}</small>
|
||||||
|
</div>
|
||||||
|
<span>{{ commit.communicationStatus }}</span>
|
||||||
|
<v-select
|
||||||
|
:model-value="commit.releaseUpdateId"
|
||||||
|
:items="updateOptions"
|
||||||
|
:label="t('releaseCommunications.commits.link')"
|
||||||
|
density="compact"
|
||||||
|
variant="outlined"
|
||||||
|
hide-details
|
||||||
|
clearable
|
||||||
|
@update:model-value="value => value ? store.linkCommit(commit.sha, value) : store.unlinkCommit(commit.sha)"
|
||||||
|
/>
|
||||||
|
<div class="commit-actions">
|
||||||
|
<v-btn size="small" variant="outlined" @click="store.markCommitInternalOnly(commit.sha)">{{ t('releaseCommunications.commits.internalOnly') }}</v-btn>
|
||||||
|
<v-btn size="small" variant="outlined" @click="store.ignoreCommit(commit.sha)">{{ t('releaseCommunications.commits.ignore') }}</v-btn>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.commits-page {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header,
|
||||||
|
.filter-panel,
|
||||||
|
.commit-row,
|
||||||
|
.commit-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
color: rgb(var(--v-theme-primary));
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-panel,
|
||||||
|
.filter-panel,
|
||||||
|
.commit-row {
|
||||||
|
border: 1px solid #d8dee8;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-panel {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-table {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-row {
|
||||||
|
align-items: center;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 120px minmax(220px, 320px) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-row > div:first-child {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.filter-panel,
|
||||||
|
.commit-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import {
|
||||||
|
RELEASE_UPDATE_AUDIENCES,
|
||||||
|
RELEASE_UPDATE_CATEGORIES,
|
||||||
|
RELEASE_UPDATE_IMPORTANCE,
|
||||||
|
useReleaseCommunicationsStore,
|
||||||
|
} from '@/features/release-communications/stores/releaseCommunicationsStore.js';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const store = useReleaseCommunicationsStore();
|
||||||
|
const editingId = ref(null);
|
||||||
|
const form = reactive({
|
||||||
|
title: '',
|
||||||
|
summary: '',
|
||||||
|
body: '',
|
||||||
|
category: 'Feature',
|
||||||
|
importance: 'Normal',
|
||||||
|
audience: 'Everyone',
|
||||||
|
deploymentLabel: '',
|
||||||
|
buildVersion: '',
|
||||||
|
commitRange: '',
|
||||||
|
});
|
||||||
|
const emailTestMode = ref(true);
|
||||||
|
const confirmResend = ref(false);
|
||||||
|
const emailResult = ref(null);
|
||||||
|
|
||||||
|
const linkedCommits = computed(() =>
|
||||||
|
editingId.value
|
||||||
|
? store.commits.filter(commit => commit.releaseUpdateId === editingId.value)
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([store.loadDeveloperUpdates(), store.loadCommits()]);
|
||||||
|
});
|
||||||
|
|
||||||
|
function editUpdate(update) {
|
||||||
|
editingId.value = update.id;
|
||||||
|
Object.assign(form, {
|
||||||
|
title: update.title ?? '',
|
||||||
|
summary: update.summary ?? '',
|
||||||
|
body: update.body ?? '',
|
||||||
|
category: update.category ?? 'Feature',
|
||||||
|
importance: update.importance ?? 'Normal',
|
||||||
|
audience: update.audience ?? 'Everyone',
|
||||||
|
deploymentLabel: update.deploymentLabel ?? '',
|
||||||
|
buildVersion: update.buildVersion ?? '',
|
||||||
|
commitRange: update.commitRange ?? '',
|
||||||
|
});
|
||||||
|
store.selectedUpdate = update;
|
||||||
|
}
|
||||||
|
|
||||||
|
function newUpdate() {
|
||||||
|
editingId.value = null;
|
||||||
|
Object.assign(form, {
|
||||||
|
title: '',
|
||||||
|
summary: '',
|
||||||
|
body: '',
|
||||||
|
category: 'Feature',
|
||||||
|
importance: 'Normal',
|
||||||
|
audience: 'Everyone',
|
||||||
|
deploymentLabel: '',
|
||||||
|
buildVersion: '',
|
||||||
|
commitRange: '',
|
||||||
|
});
|
||||||
|
emailResult.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
await store.saveDeveloperUpdate({ ...form }, editingId.value);
|
||||||
|
editingId.value = store.selectedUpdate?.id ?? editingId.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendEmail() {
|
||||||
|
if (!editingId.value || !window.confirm(t('releaseCommunications.developer.confirmEmail'))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emailResult.value = await store.sendUpdateEmail(editingId.value, {
|
||||||
|
testMode: emailTestMode.value,
|
||||||
|
confirmResend: confirmResend.value,
|
||||||
|
});
|
||||||
|
await store.loadDeveloperUpdate(editingId.value);
|
||||||
|
await store.loadDeveloperUpdates();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value) {
|
||||||
|
return value ? new Date(value).toLocaleString() : t('releaseCommunications.emptyValue');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="developer-updates-page">
|
||||||
|
<header class="page-header">
|
||||||
|
<div>
|
||||||
|
<div class="eyebrow">{{ t('releaseCommunications.developer.eyebrow') }}</div>
|
||||||
|
<h1>{{ t('releaseCommunications.developer.title') }}</h1>
|
||||||
|
</div>
|
||||||
|
<v-btn @click="newUpdate">{{ t('releaseCommunications.developer.newUpdate') }}</v-btn>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="editor-grid">
|
||||||
|
<form
|
||||||
|
class="editor-panel"
|
||||||
|
@submit.prevent="save"
|
||||||
|
>
|
||||||
|
<v-text-field v-model="form.title" :label="t('title')" density="compact" variant="outlined" />
|
||||||
|
<v-textarea v-model="form.summary" :label="t('releaseCommunications.summary')" rows="2" variant="outlined" />
|
||||||
|
<v-textarea v-model="form.body" :label="t('releaseCommunications.body')" rows="5" variant="outlined" />
|
||||||
|
<div class="form-row">
|
||||||
|
<v-select v-model="form.category" :items="RELEASE_UPDATE_CATEGORIES" :label="t('releaseCommunications.category')" density="compact" variant="outlined" />
|
||||||
|
<v-select v-model="form.importance" :items="RELEASE_UPDATE_IMPORTANCE" :label="t('releaseCommunications.importance')" density="compact" variant="outlined" />
|
||||||
|
<v-select v-model="form.audience" :items="RELEASE_UPDATE_AUDIENCES" :label="t('releaseCommunications.audience')" density="compact" variant="outlined" />
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<v-text-field v-model="form.deploymentLabel" :label="t('releaseCommunications.deploymentLabel')" density="compact" variant="outlined" />
|
||||||
|
<v-text-field v-model="form.buildVersion" :label="t('releaseCommunications.buildVersion')" density="compact" variant="outlined" />
|
||||||
|
<v-text-field v-model="form.commitRange" :label="t('releaseCommunications.commitRange')" density="compact" variant="outlined" />
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<v-btn type="submit" :loading="store.isSaving">{{ t('save') }}</v-btn>
|
||||||
|
<v-btn v-if="editingId" variant="outlined" @click="store.publishUpdate(editingId)">{{ t('releaseCommunications.developer.publish') }}</v-btn>
|
||||||
|
<v-btn v-if="editingId" variant="outlined" @click="store.archiveUpdate(editingId)">{{ t('releaseCommunications.developer.archive') }}</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="editingId"
|
||||||
|
class="email-panel"
|
||||||
|
>
|
||||||
|
<strong>{{ t('releaseCommunications.developer.pushEmail') }}</strong>
|
||||||
|
<v-checkbox v-model="emailTestMode" :label="t('releaseCommunications.developer.testMode')" density="compact" hide-details />
|
||||||
|
<v-checkbox v-model="confirmResend" :label="t('releaseCommunications.developer.confirmResend')" density="compact" hide-details />
|
||||||
|
<v-btn variant="outlined" :loading="store.isSendingEmail" @click="sendEmail">{{ t('releaseCommunications.developer.sendEmail') }}</v-btn>
|
||||||
|
<small v-if="emailResult">{{ t('releaseCommunications.developer.emailResult', { count: emailResult.recipientCount }) }}</small>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<aside class="updates-panel">
|
||||||
|
<button
|
||||||
|
v-for="update in store.developerUpdates"
|
||||||
|
:key="update.id"
|
||||||
|
class="update-row"
|
||||||
|
type="button"
|
||||||
|
@click="editUpdate(update)"
|
||||||
|
>
|
||||||
|
<strong>{{ update.title }}</strong>
|
||||||
|
<span>{{ update.status }} / {{ update.audience }}</span>
|
||||||
|
<small>{{ formatDate(update.publishedAt ?? update.createdAt) }}</small>
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
v-if="editingId"
|
||||||
|
class="linked-commits"
|
||||||
|
>
|
||||||
|
<h2>{{ t('releaseCommunications.developer.linkedCommits') }}</h2>
|
||||||
|
<div v-if="!linkedCommits.length" class="page-message">{{ t('releaseCommunications.developer.noLinkedCommits') }}</div>
|
||||||
|
<div
|
||||||
|
v-for="commit in linkedCommits"
|
||||||
|
:key="commit.sha"
|
||||||
|
class="commit-chip"
|
||||||
|
>
|
||||||
|
<code>{{ commit.shortSha }}</code>
|
||||||
|
<span>{{ commit.subject }}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.developer-updates-page {
|
||||||
|
display: grid;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header,
|
||||||
|
.actions,
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
color: rgb(var(--v-theme-primary));
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.3fr) minmax(280px, 0.7fr);
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-panel,
|
||||||
|
.updates-panel,
|
||||||
|
.linked-commits {
|
||||||
|
border: 1px solid #d8dee8;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-row {
|
||||||
|
display: grid;
|
||||||
|
width: 100%;
|
||||||
|
gap: 3px;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
background: transparent;
|
||||||
|
padding: 10px 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 16px;
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
padding-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-chip {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.editor-grid,
|
||||||
|
.form-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, onMounted } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import { useReleaseCommunicationsStore } from '@/features/release-communications/stores/releaseCommunicationsStore.js';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const route = useRoute();
|
||||||
|
const store = useReleaseCommunicationsStore();
|
||||||
|
|
||||||
|
const highlightedId = computed(() => route.query.updateId);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await store.loadUserUpdates();
|
||||||
|
if (highlightedId.value) {
|
||||||
|
await store.markRead(highlightedId.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatDate(value) {
|
||||||
|
return value ? new Date(value).toLocaleString() : '';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="updates-page">
|
||||||
|
<header class="updates-header">
|
||||||
|
<div>
|
||||||
|
<div class="eyebrow">{{ t('releaseCommunications.user.eyebrow') }}</div>
|
||||||
|
<h1>{{ t('releaseCommunications.user.title') }}</h1>
|
||||||
|
<p>{{ t('releaseCommunications.user.description') }}</p>
|
||||||
|
</div>
|
||||||
|
<v-btn
|
||||||
|
variant="outlined"
|
||||||
|
:disabled="!store.unreadCount"
|
||||||
|
@click="store.markAllRead"
|
||||||
|
>
|
||||||
|
{{ t('releaseCommunications.user.markAllRead') }}
|
||||||
|
</v-btn>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="store.isLoading"
|
||||||
|
class="page-message"
|
||||||
|
>
|
||||||
|
{{ t('loading') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section
|
||||||
|
v-else
|
||||||
|
class="updates-list"
|
||||||
|
>
|
||||||
|
<article
|
||||||
|
v-for="update in store.updates"
|
||||||
|
:key="update.id"
|
||||||
|
class="update-entry"
|
||||||
|
:class="{ 'update-entry-unread': !update.isRead, 'update-entry-highlight': update.id === highlightedId }"
|
||||||
|
@click="!update.isRead && store.markRead(update.id)"
|
||||||
|
>
|
||||||
|
<div class="update-meta">
|
||||||
|
<span>{{ update.category }}</span>
|
||||||
|
<span>{{ update.importance }}</span>
|
||||||
|
<time>{{ formatDate(update.publishedAt) }}</time>
|
||||||
|
</div>
|
||||||
|
<h2>{{ update.title }}</h2>
|
||||||
|
<p>{{ update.summary }}</p>
|
||||||
|
<div
|
||||||
|
v-if="update.body"
|
||||||
|
class="update-body"
|
||||||
|
>
|
||||||
|
{{ update.body }}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="!store.updates.length"
|
||||||
|
class="page-message"
|
||||||
|
>
|
||||||
|
{{ t('releaseCommunications.user.empty') }}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.updates-page {
|
||||||
|
display: grid;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.updates-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow,
|
||||||
|
.update-meta {
|
||||||
|
color: rgb(var(--v-theme-primary));
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.updates-header h1 {
|
||||||
|
margin: 4px 0;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.updates-header p {
|
||||||
|
margin: 0;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.updates-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-entry {
|
||||||
|
border: 1px solid #d8dee8;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-entry-unread {
|
||||||
|
border-color: rgb(var(--v-theme-primary));
|
||||||
|
box-shadow: inset 3px 0 0 rgb(var(--v-theme-primary));
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-entry-highlight {
|
||||||
|
outline: 2px solid rgb(var(--v-theme-primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-entry h2 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-entry p {
|
||||||
|
margin: 0;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-body {
|
||||||
|
margin-top: 12px;
|
||||||
|
color: #475569;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-message {
|
||||||
|
padding: 24px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
import { useAuthStore } from '@/features/auth/stores/authStore.js';
|
import { useAuthStore } from '@/features/auth/stores/authStore.js';
|
||||||
import { useChannelsStore } from '@/features/channels/stores/channelsStore.js';
|
import { useChannelsStore } from '@/features/channels/stores/channelsStore.js';
|
||||||
import { useNotificationsStore } from '@/features/notifications/stores/notificationsStore.js';
|
import { useNotificationsStore } from '@/features/notifications/stores/notificationsStore.js';
|
||||||
|
import { useReleaseCommunicationsStore } from '@/features/release-communications/stores/releaseCommunicationsStore.js';
|
||||||
import { getNotificationRoute } from '@/features/notifications/notificationRoutes.js';
|
import { getNotificationRoute } from '@/features/notifications/notificationRoutes.js';
|
||||||
import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js';
|
import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js';
|
||||||
import { useCampaignsStore } from '@/features/campaigns/stores/campaignsStore.js';
|
import { useCampaignsStore } from '@/features/campaigns/stores/campaignsStore.js';
|
||||||
@@ -22,6 +23,8 @@
|
|||||||
mdiMagnify,
|
mdiMagnify,
|
||||||
mdiPlus,
|
mdiPlus,
|
||||||
mdiBugOutline,
|
mdiBugOutline,
|
||||||
|
mdiBullhornOutline,
|
||||||
|
mdiSourceCommit,
|
||||||
} from '@mdi/js';
|
} from '@mdi/js';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -38,6 +41,7 @@
|
|||||||
const channelsStore = useChannelsStore();
|
const channelsStore = useChannelsStore();
|
||||||
const contentItemsStore = useContentItemsStore();
|
const contentItemsStore = useContentItemsStore();
|
||||||
const notificationsStore = useNotificationsStore();
|
const notificationsStore = useNotificationsStore();
|
||||||
|
const releaseCommunicationsStore = useReleaseCommunicationsStore();
|
||||||
const campaignsStore = useCampaignsStore();
|
const campaignsStore = useCampaignsStore();
|
||||||
const isNotificationsOpen = ref(false);
|
const isNotificationsOpen = ref(false);
|
||||||
const isSearchFocused = ref(false);
|
const isSearchFocused = ref(false);
|
||||||
@@ -51,7 +55,10 @@
|
|||||||
const primaryLinks = [
|
const primaryLinks = [
|
||||||
{ to: '/app/dashboard', labelKey: 'nav.overview', icon: mdiHomeOutline },
|
{ to: '/app/dashboard', labelKey: 'nav.overview', icon: mdiHomeOutline },
|
||||||
{ to: '/app/media-library', labelKey: 'nav.mediaLibrary', icon: mdiImageMultipleOutline },
|
{ to: '/app/media-library', labelKey: 'nav.mediaLibrary', icon: mdiImageMultipleOutline },
|
||||||
|
{ to: '/app/updates', labelKey: 'nav.whatsNew', icon: mdiBullhornOutline, badge: 'updates' },
|
||||||
{ to: '/app/feedback', labelKey: 'nav.feedbackReview', icon: mdiBugOutline, roles: ['developer'] },
|
{ to: '/app/feedback', labelKey: 'nav.feedbackReview', icon: mdiBugOutline, roles: ['developer'] },
|
||||||
|
{ to: '/app/developer/updates', labelKey: 'nav.releaseUpdates', icon: mdiBullhornOutline, roles: ['developer'] },
|
||||||
|
{ to: '/app/developer/release-commits', labelKey: 'nav.releaseCommits', icon: mdiSourceCommit, roles: ['developer'], badge: 'commits' },
|
||||||
];
|
];
|
||||||
const visiblePrimaryLinks = computed(() =>
|
const visiblePrimaryLinks = computed(() =>
|
||||||
primaryLinks.filter(link => !link.roles || authStore.hasAnyRole(link.roles))
|
primaryLinks.filter(link => !link.roles || authStore.hasAnyRole(link.roles))
|
||||||
@@ -231,6 +238,14 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
releaseCommunicationsStore.loadUnreadSummary().catch(error => {
|
||||||
|
console.error('Failed to load release update unread count:', error);
|
||||||
|
});
|
||||||
|
if (authStore.hasAnyRole(['developer'])) {
|
||||||
|
releaseCommunicationsStore.loadCommits().catch(error => {
|
||||||
|
console.error('Failed to load release commit count:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
document.addEventListener('click', handleDocumentClick);
|
document.addEventListener('click', handleDocumentClick);
|
||||||
window.addEventListener('resize', updateCollapsedSearchPanelPosition);
|
window.addEventListener('resize', updateCollapsedSearchPanelPosition);
|
||||||
window.addEventListener('scroll', updateCollapsedSearchPanelPosition, true);
|
window.addEventListener('scroll', updateCollapsedSearchPanelPosition, true);
|
||||||
@@ -450,7 +465,21 @@
|
|||||||
active-class="sidebar-link-active"
|
active-class="sidebar-link-active"
|
||||||
:title="!isExpanded ? t(link.labelKey) : null"
|
:title="!isExpanded ? t(link.labelKey) : null"
|
||||||
>
|
>
|
||||||
|
<span class="sidebar-link-icon-wrap">
|
||||||
<v-icon :icon="link.icon" />
|
<v-icon :icon="link.icon" />
|
||||||
|
<span
|
||||||
|
v-if="link.badge === 'updates' && releaseCommunicationsStore.unreadCount"
|
||||||
|
class="sidebar-notification-badge"
|
||||||
|
>
|
||||||
|
{{ Math.min(releaseCommunicationsStore.unreadCount, 9) }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="link.badge === 'commits' && releaseCommunicationsStore.unreviewedCommitCount"
|
||||||
|
class="sidebar-notification-badge"
|
||||||
|
>
|
||||||
|
{{ Math.min(releaseCommunicationsStore.unreviewedCommitCount, 9) }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
<span
|
<span
|
||||||
v-if="isExpanded"
|
v-if="isExpanded"
|
||||||
class="sidebar-link-label"
|
class="sidebar-link-label"
|
||||||
@@ -781,6 +810,10 @@
|
|||||||
@apply relative flex items-center justify-center;
|
@apply relative flex items-center justify-center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-link-icon-wrap {
|
||||||
|
@apply relative flex items-center justify-center;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-notification-badge {
|
.sidebar-notification-badge {
|
||||||
@apply absolute -right-2 -top-2 flex h-5 min-w-[1.25rem] items-center justify-center rounded-full px-1 text-[10px] font-black;
|
@apply absolute -right-2 -top-2 flex h-5 min-w-[1.25rem] items-center justify-center rounded-full px-1 text-[10px] font-black;
|
||||||
background: #ef4444;
|
background: #ef4444;
|
||||||
|
|||||||
@@ -560,6 +560,9 @@
|
|||||||
"mediaLibrary": "Media Library",
|
"mediaLibrary": "Media Library",
|
||||||
"myFeedback": "My Feedback",
|
"myFeedback": "My Feedback",
|
||||||
"feedbackReview": "Feedback Review",
|
"feedbackReview": "Feedback Review",
|
||||||
|
"whatsNew": "What's New",
|
||||||
|
"releaseUpdates": "Release Updates",
|
||||||
|
"releaseCommits": "Release Commits",
|
||||||
"channels": "Channels",
|
"channels": "Channels",
|
||||||
"campaigns": "Campaigns",
|
"campaigns": "Campaigns",
|
||||||
"reviewQueue": "Review Queue",
|
"reviewQueue": "Review Queue",
|
||||||
@@ -592,6 +595,58 @@
|
|||||||
"feedbackReporterCommented": "Reporter replied"
|
"feedbackReporterCommented": "Reporter replied"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"releaseCommunications": {
|
||||||
|
"summary": "Summary",
|
||||||
|
"body": "Body",
|
||||||
|
"category": "Category",
|
||||||
|
"importance": "Importance",
|
||||||
|
"audience": "Audience",
|
||||||
|
"deploymentLabel": "Deployment label",
|
||||||
|
"buildVersion": "Build version",
|
||||||
|
"commitRange": "Commit range",
|
||||||
|
"emptyValue": "Not set",
|
||||||
|
"errors": {
|
||||||
|
"loadFailed": "Could not load product updates."
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"eyebrow": "Product updates",
|
||||||
|
"title": "What's New",
|
||||||
|
"description": "Features, improvements, and fixes published since you last checked.",
|
||||||
|
"markAllRead": "Mark all read",
|
||||||
|
"empty": "No published updates yet."
|
||||||
|
},
|
||||||
|
"developer": {
|
||||||
|
"eyebrow": "SaaS operator",
|
||||||
|
"title": "Release updates",
|
||||||
|
"newUpdate": "New update",
|
||||||
|
"publish": "Publish",
|
||||||
|
"archive": "Archive",
|
||||||
|
"pushEmail": "Push email",
|
||||||
|
"testMode": "Send to me only",
|
||||||
|
"confirmResend": "Confirm resend",
|
||||||
|
"sendEmail": "Send email",
|
||||||
|
"confirmEmail": "Send this release update by email?",
|
||||||
|
"emailResult": "Email sent to {count} recipient(s).",
|
||||||
|
"linkedCommits": "Linked commits",
|
||||||
|
"noLinkedCommits": "No commits linked to this update yet."
|
||||||
|
},
|
||||||
|
"commits": {
|
||||||
|
"eyebrow": "SaaS operator",
|
||||||
|
"title": "Release commits",
|
||||||
|
"description": "Import shipped commits and reconcile them with curated update entries.",
|
||||||
|
"unreviewed": "unreviewed",
|
||||||
|
"importJson": "Commit JSON payload",
|
||||||
|
"import": "Import commits",
|
||||||
|
"search": "Search",
|
||||||
|
"status": "Status",
|
||||||
|
"linkedUpdate": "Linked update",
|
||||||
|
"author": "Author",
|
||||||
|
"clear": "Clear",
|
||||||
|
"link": "Update",
|
||||||
|
"internalOnly": "Internal only",
|
||||||
|
"ignore": "Ignore"
|
||||||
|
}
|
||||||
|
},
|
||||||
"feedback": {
|
"feedback": {
|
||||||
"button": "Feedback",
|
"button": "Feedback",
|
||||||
"open": "Send product feedback",
|
"open": "Send product feedback",
|
||||||
|
|||||||
@@ -560,6 +560,9 @@
|
|||||||
"mediaLibrary": "Bibliotheque media",
|
"mediaLibrary": "Bibliotheque media",
|
||||||
"myFeedback": "Mon feedback",
|
"myFeedback": "Mon feedback",
|
||||||
"feedbackReview": "Revue feedback",
|
"feedbackReview": "Revue feedback",
|
||||||
|
"whatsNew": "Nouveautés",
|
||||||
|
"releaseUpdates": "Mises à jour",
|
||||||
|
"releaseCommits": "Commits release",
|
||||||
"channels": "Canaux",
|
"channels": "Canaux",
|
||||||
"campaigns": "Campagnes",
|
"campaigns": "Campagnes",
|
||||||
"reviewQueue": "File de révision",
|
"reviewQueue": "File de révision",
|
||||||
@@ -592,6 +595,58 @@
|
|||||||
"feedbackReporterCommented": "Réponse du rapporteur"
|
"feedbackReporterCommented": "Réponse du rapporteur"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"releaseCommunications": {
|
||||||
|
"summary": "Résumé",
|
||||||
|
"body": "Détail",
|
||||||
|
"category": "Catégorie",
|
||||||
|
"importance": "Importance",
|
||||||
|
"audience": "Audience",
|
||||||
|
"deploymentLabel": "Libellé de déploiement",
|
||||||
|
"buildVersion": "Version build",
|
||||||
|
"commitRange": "Plage de commits",
|
||||||
|
"emptyValue": "Non défini",
|
||||||
|
"errors": {
|
||||||
|
"loadFailed": "Impossible de charger les mises à jour produit."
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"eyebrow": "Mises à jour produit",
|
||||||
|
"title": "Nouveautés",
|
||||||
|
"description": "Fonctionnalités, améliorations et corrections publiées depuis votre dernière visite.",
|
||||||
|
"markAllRead": "Tout marquer lu",
|
||||||
|
"empty": "Aucune mise à jour publiée pour le moment."
|
||||||
|
},
|
||||||
|
"developer": {
|
||||||
|
"eyebrow": "Opérateur SaaS",
|
||||||
|
"title": "Mises à jour release",
|
||||||
|
"newUpdate": "Nouvelle mise à jour",
|
||||||
|
"publish": "Publier",
|
||||||
|
"archive": "Archiver",
|
||||||
|
"pushEmail": "Email push",
|
||||||
|
"testMode": "M'envoyer seulement",
|
||||||
|
"confirmResend": "Confirmer le renvoi",
|
||||||
|
"sendEmail": "Envoyer email",
|
||||||
|
"confirmEmail": "Envoyer cette mise à jour par email?",
|
||||||
|
"emailResult": "Email envoyé à {count} destinataire(s).",
|
||||||
|
"linkedCommits": "Commits liés",
|
||||||
|
"noLinkedCommits": "Aucun commit lié à cette mise à jour."
|
||||||
|
},
|
||||||
|
"commits": {
|
||||||
|
"eyebrow": "Opérateur SaaS",
|
||||||
|
"title": "Commits release",
|
||||||
|
"description": "Importez les commits livrés et associez-les aux mises à jour rédigées.",
|
||||||
|
"unreviewed": "non révisés",
|
||||||
|
"importJson": "Payload JSON de commits",
|
||||||
|
"import": "Importer commits",
|
||||||
|
"search": "Recherche",
|
||||||
|
"status": "Statut",
|
||||||
|
"linkedUpdate": "Mise à jour liée",
|
||||||
|
"author": "Auteur",
|
||||||
|
"clear": "Effacer",
|
||||||
|
"link": "Mise à jour",
|
||||||
|
"internalOnly": "Interne seulement",
|
||||||
|
"ignore": "Ignorer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"feedback": {
|
"feedback": {
|
||||||
"button": "Feedback",
|
"button": "Feedback",
|
||||||
"open": "Envoyer un feedback produit",
|
"open": "Envoyer un feedback produit",
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import { useContentItemsStore } from '@/features/content/stores/contentItemsStor
|
|||||||
import { useClientsStore } from '@/features/clients/stores/clientsStore.js';
|
import { useClientsStore } from '@/features/clients/stores/clientsStore.js';
|
||||||
import { useCampaignsStore } from '@/features/campaigns/stores/campaignsStore.js';
|
import { useCampaignsStore } from '@/features/campaigns/stores/campaignsStore.js';
|
||||||
import { useNotificationsStore } from '@/features/notifications/stores/notificationsStore.js';
|
import { useNotificationsStore } from '@/features/notifications/stores/notificationsStore.js';
|
||||||
|
import { useReleaseCommunicationsStore } from '@/features/release-communications/stores/releaseCommunicationsStore.js';
|
||||||
import { useChannelsStore } from '@/features/channels/stores/channelsStore.js';
|
import { useChannelsStore } from '@/features/channels/stores/channelsStore.js';
|
||||||
import { i18n } from '@/plugins/i18n.js';
|
import { i18n } from '@/plugins/i18n.js';
|
||||||
import config from '@/config.js';
|
import config from '@/config.js';
|
||||||
@@ -101,5 +102,6 @@ useChannelsStore();
|
|||||||
useReviewQueueStore();
|
useReviewQueueStore();
|
||||||
useContentItemsStore();
|
useContentItemsStore();
|
||||||
useNotificationsStore();
|
useNotificationsStore();
|
||||||
|
useReleaseCommunicationsStore();
|
||||||
|
|
||||||
app.mount('#app');
|
app.mount('#app');
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ const MyFeedbackListView = () => import('@/features/feedback/views/MyFeedbackLis
|
|||||||
const MyFeedbackDetailView = () => import('@/features/feedback/views/MyFeedbackDetailView.vue');
|
const MyFeedbackDetailView = () => import('@/features/feedback/views/MyFeedbackDetailView.vue');
|
||||||
const DeveloperFeedbackListView = () => import('@/features/feedback/views/DeveloperFeedbackListView.vue');
|
const DeveloperFeedbackListView = () => import('@/features/feedback/views/DeveloperFeedbackListView.vue');
|
||||||
const DeveloperFeedbackDetailView = () => import('@/features/feedback/views/DeveloperFeedbackDetailView.vue');
|
const DeveloperFeedbackDetailView = () => import('@/features/feedback/views/DeveloperFeedbackDetailView.vue');
|
||||||
|
const UpdatesView = () => import('@/features/release-communications/views/UpdatesView.vue');
|
||||||
|
const DeveloperUpdatesView = () => import('@/features/release-communications/views/DeveloperUpdatesView.vue');
|
||||||
|
const DeveloperReleaseCommitsView = () => import('@/features/release-communications/views/DeveloperReleaseCommitsView.vue');
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{
|
{
|
||||||
@@ -123,6 +126,24 @@ const routes = [
|
|||||||
component: MyFeedbackDetailView,
|
component: MyFeedbackDetailView,
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/app/updates',
|
||||||
|
name: 'release-updates',
|
||||||
|
component: UpdatesView,
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/app/developer/updates',
|
||||||
|
name: 'developer-release-updates',
|
||||||
|
component: DeveloperUpdatesView,
|
||||||
|
meta: { requiresAuth: true, roles: ['developer'] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/app/developer/release-commits',
|
||||||
|
name: 'developer-release-commits',
|
||||||
|
component: DeveloperReleaseCommitsView,
|
||||||
|
meta: { requiresAuth: true, roles: ['developer'] },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/app/feedback',
|
path: '/app/feedback',
|
||||||
name: 'developer-feedback',
|
name: 'developer-feedback',
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user