diff --git a/backend/src/Socialize.Api/Data/AppDbContext.cs b/backend/src/Socialize.Api/Data/AppDbContext.cs index 989387f8..f40584d8 100644 --- a/backend/src/Socialize.Api/Data/AppDbContext.cs +++ b/backend/src/Socialize.Api/Data/AppDbContext.cs @@ -12,6 +12,7 @@ using Socialize.Api.Modules.Notifications.Data; using Socialize.Api.Modules.Campaigns.Data; using Socialize.Api.Modules.CalendarIntegrations.Data; using Socialize.Api.Modules.Organizations.Data; +using Socialize.Api.Modules.ReleaseCommunications.Data; using Socialize.Api.Modules.Workspaces.Data; namespace Socialize.Api.Data; @@ -50,6 +51,10 @@ internal class AppDbContext( public DbSet CalendarCatalogEntries => Set(); public DbSet CalendarEvents => Set(); public DbSet UserCalendarExportFeeds => Set(); + public DbSet ReleaseUpdates => Set(); + public DbSet ReleaseUpdateReadReceipts => Set(); + public DbSet ReleaseCommits => Set(); + public DbSet ReleaseUpdateEmailDigestReceipts => Set(); protected override void OnModelCreating(ModelBuilder builder) { @@ -67,5 +72,6 @@ internal class AppDbContext( builder.ConfigureNotificationsModule(); builder.ConfigureFeedbackModule(); builder.ConfigureCalendarIntegrationsModule(); + builder.ConfigureReleaseCommunicationsModule(); } } diff --git a/backend/src/Socialize.Api/Migrations/20260508010206_AddReleaseCommunications.Designer.cs b/backend/src/Socialize.Api/Migrations/20260508010206_AddReleaseCommunications.Designer.cs new file mode 100644 index 00000000..185a2927 --- /dev/null +++ b/backend/src/Socialize.Api/Migrations/20260508010206_AddReleaseCommunications.Designer.cs @@ -0,0 +1,2628 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Socialize.Api.Data; + +#nullable disable + +namespace Socialize.Api.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260508010206_AddReleaseCommunications")] + partial class AddReleaseCommunications + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Approvals.Data.ApprovalDecision", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovalRequestId") + .HasColumnType("uuid"); + + b.Property("Comment") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DecidedByEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("DecidedByName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("DecidedByUserId") + .HasColumnType("uuid"); + + b.Property("Decision") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("ApprovalRequestId"); + + b.ToTable("ApprovalDecisions", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Approvals.Data.ApprovalRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessToken") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ContentItemId") + .HasColumnType("uuid"); + + b.Property("DueAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RequestedByUserId") + .HasColumnType("uuid"); + + b.Property("ReviewerEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ReviewerName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Stage") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("State") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("WorkflowInstanceId") + .HasColumnType("uuid"); + + b.Property("WorkflowStepRequiredApproverCount") + .HasColumnType("integer"); + + b.Property("WorkflowStepSortOrder") + .HasColumnType("integer"); + + b.Property("WorkflowStepTargetType") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("WorkflowStepTargetValue") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ContentItemId"); + + b.HasIndex("ReviewerEmail"); + + b.HasIndex("WorkflowInstanceId"); + + b.HasIndex("WorkspaceId"); + + b.ToTable("ApprovalRequests", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Approvals.Data.ApprovalWorkflowInstance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovalMode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ContentItemId") + .HasColumnType("uuid"); + + b.Property("StartedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("State") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ContentItemId"); + + b.HasIndex("WorkspaceId"); + + b.HasIndex("ContentItemId", "State") + .IsUnique() + .HasFilter("\"State\" = 'Pending'"); + + b.ToTable("ApprovalWorkflowInstances", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Approvals.Data.WorkspaceApprovalStepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("RequiredApproverCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TargetValue") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorkspaceId"); + + b.HasIndex("WorkspaceId", "SortOrder") + .IsUnique(); + + b.ToTable("WorkspaceApprovalStepConfigurations", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Assets.Data.Asset", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssetType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ContentItemId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CurrentRevisionNumber") + .HasColumnType("integer"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("GoogleDriveFileId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("GoogleDriveLink") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("PreviewUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("SourceType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ContentItemId"); + + b.HasIndex("WorkspaceId"); + + b.ToTable("Assets", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Assets.Data.AssetRevision", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssetId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatedByUserId") + .HasColumnType("uuid"); + + b.Property("Notes") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("PreviewUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("RevisionNumber") + .HasColumnType("integer"); + + b.Property("SourceReference") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.HasKey("Id"); + + b.HasIndex("AssetId"); + + b.HasIndex("AssetId", "RevisionNumber") + .IsUnique(); + + b.ToTable("AssetRevisions", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.CalendarIntegrations.Data.CalendarCatalogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Country") + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CultureOrReligion") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("DefaultColor") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("ProviderName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Region") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SourceUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TrustLevel") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("Category"); + + b.HasIndex("Country"); + + b.HasIndex("ProviderName"); + + b.ToTable("CalendarCatalogEntries", (string)null); + + b.HasData( + new + { + Id = new Guid("10000000-0000-0000-0000-000000000001"), + Category = "public-holiday", + Country = "US", + CreatedAt = new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DefaultColor = "#2F80ED", + Description = "Federal public holiday calendar for the United States.", + Language = "en", + ProviderName = "Nager.Date", + SourceUrl = "https://date.nager.at/api/v3/PublicHolidays/2026/US", + Title = "United States Public Holidays", + TrustLevel = "Verified" + }, + new + { + Id = new Guid("10000000-0000-0000-0000-000000000002"), + Category = "public-holiday", + Country = "CA", + CreatedAt = new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DefaultColor = "#2F80ED", + Description = "Public holiday calendar for Canada.", + Language = "en", + ProviderName = "Nager.Date", + SourceUrl = "https://date.nager.at/api/v3/PublicHolidays/2026/CA", + Title = "Canada Public Holidays", + TrustLevel = "Verified" + }, + new + { + Id = new Guid("10000000-0000-0000-0000-000000000003"), + Category = "marketing-moment", + CreatedAt = new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DefaultColor = "#9B51E0", + Description = "Common retail, awareness, and social planning moments.", + Language = "en", + ProviderName = "Socialize", + SourceUrl = "https://example.com/socialize/marketing-moments.ics", + Title = "Common Marketing Moments", + TrustLevel = "Maintained" + }); + }); + + modelBuilder.Entity("Socialize.Api.Modules.CalendarIntegrations.Data.CalendarEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CalendarSourceId") + .HasColumnType("uuid"); + + b.Property("Description") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("EndLocalDateTime") + .HasColumnType("timestamp with time zone"); + + b.Property("EndUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ImportedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsAllDay") + .HasColumnType("boolean"); + + b.Property("IsFloatingTime") + .HasColumnType("boolean"); + + b.Property("Location") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RecurrenceId") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("SourceEventUid") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("SourceLastModifiedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("StartDate") + .HasColumnType("date"); + + b.Property("StartLocalDateTime") + .HasColumnType("timestamp with time zone"); + + b.Property("StartUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TimeZoneId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("CalendarSourceId"); + + b.HasIndex("CalendarSourceId", "SourceEventUid", "StartDate") + .IsUnique(); + + b.ToTable("CalendarEvents", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.CalendarIntegrations.Data.CalendarSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CatalogSourceReference") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Color") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DisplayTitle") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("InheritanceMode") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("LastAttemptedSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSuccessfulSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSyncError") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SourceUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("Scope"); + + b.HasIndex("UserId"); + + b.HasIndex("WorkspaceId"); + + b.ToTable("CalendarSources", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.CalendarIntegrations.Data.UserCalendarExportFeed", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Token") + .HasMaxLength(96) + .HasColumnType("character varying(96)"); + + b.Property("TokenHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("UserCalendarExportFeeds", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Campaigns.Data.Campaign", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClientId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Description") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Notes") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ClientId"); + + b.HasIndex("WorkspaceId"); + + b.HasIndex("ClientId", "Name") + .IsUnique(); + + b.ToTable("Campaigns", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Channels.Data.Channel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("ExternalUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Handle") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Network") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorkspaceId"); + + b.HasIndex("WorkspaceId", "Network", "Name") + .IsUnique(); + + b.ToTable("Channels", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Clients.Data.Client", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PortraitUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("PrimaryContactEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PrimaryContactName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PrimaryContactPortraitUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorkspaceId"); + + b.HasIndex("WorkspaceId", "Name") + .IsUnique(); + + b.ToTable("Clients", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Comments.Data.Comment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AttachmentBlobContainerName") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("AttachmentBlobName") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("AttachmentBlobUrl") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("AttachmentContentType") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("AttachmentFileName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("AttachmentSizeBytes") + .HasColumnType("bigint"); + + b.Property("AuthorDisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("AuthorEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("AuthorUserId") + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ContentItemId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("ParentCommentId") + .HasColumnType("uuid"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ContentItemId"); + + b.HasIndex("ParentCommentId"); + + b.HasIndex("WorkspaceId"); + + b.ToTable("Comments", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.ContentItems.Data.ContentItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CampaignId") + .HasColumnType("uuid"); + + b.Property("ClientId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CurrentRevisionLabel") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CurrentRevisionNumber") + .HasColumnType("integer"); + + b.Property("DueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Hashtags") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("PublicationMessage") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("PublicationTargets") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CampaignId"); + + b.HasIndex("ClientId"); + + b.HasIndex("WorkspaceId"); + + b.ToTable("ContentItems", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.ContentItems.Data.ContentItemActivityEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActorEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ActorUserId") + .HasColumnType("uuid"); + + b.Property("ContentItemId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("EntityId") + .HasColumnType("uuid"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("MetadataJson") + .HasColumnType("jsonb"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ContentItemId"); + + b.HasIndex("WorkspaceId"); + + b.HasIndex("ContentItemId", "CreatedAt"); + + b.ToTable("ContentItemActivityEntries", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.ContentItems.Data.ContentItemRevision", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChangeSummary") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ContentItemId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatedByUserId") + .HasColumnType("uuid"); + + b.Property("Hashtags") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("PublicationMessage") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("PublicationTargets") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RevisionLabel") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("RevisionNumber") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("ContentItemId"); + + b.HasIndex("ContentItemId", "RevisionNumber") + .IsUnique(); + + b.ToTable("ContentItemRevisions", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackActivityEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActivityType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ActorDisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ActorEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ActorUserId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("FeedbackReportId") + .HasColumnType("uuid"); + + b.Property("FromValue") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Note") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("ToValue") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("FeedbackReportId"); + + b.ToTable("FeedbackActivityEntries", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackComment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthorDisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("AuthorEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("AuthorRole") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("AuthorUserId") + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("character varying(8000)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("FeedbackReportId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AuthorUserId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("FeedbackReportId"); + + b.ToTable("FeedbackComments", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackReport", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppVersion") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("BrowserUserAgent") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("CampaignId") + .HasColumnType("uuid"); + + b.Property("CampaignName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CancellationReason") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("CancelledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CancelledByUserId") + .HasColumnType("uuid"); + + b.Property("ClientId") + .HasColumnType("uuid"); + + b.Property("ClientName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ContentItemId") + .HasColumnType("uuid"); + + b.Property("ContentItemTitle") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("character varying(8000)"); + + b.Property("LastActivityAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReporterDisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ReporterEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ReporterUserId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SubmittedPath") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ViewportHeight") + .HasColumnType("integer"); + + b.Property("ViewportWidth") + .HasColumnType("integer"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.Property("WorkspaceName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("CampaignId"); + + b.HasIndex("ClientId"); + + b.HasIndex("ContentItemId"); + + b.HasIndex("LastActivityAt"); + + b.HasIndex("ReporterUserId"); + + b.HasIndex("Status"); + + b.HasIndex("Type"); + + b.HasIndex("WorkspaceId"); + + b.ToTable("FeedbackReports", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackScreenshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BlobContainerName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("FeedbackReportId") + .HasColumnType("uuid"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("FeedbackReportId") + .IsUnique(); + + b.ToTable("FeedbackScreenshots", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("FeedbackReportId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName"); + + b.HasIndex("FeedbackReportId", "NormalizedName") + .IsUnique(); + + b.ToTable("FeedbackTags", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Identity.Data.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Identity.Data.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("Address") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Alias") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("BirthDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("FacebookId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Firstname") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("GoogleId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LastAuthenticatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Lastname") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("PortraitUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("RefreshToken") + .HasMaxLength(44) + .HasColumnType("character varying(44)"); + + b.Property("RefreshTokenExpiryTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Notifications.Data.NotificationEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContentItemId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("EntityId") + .HasColumnType("uuid"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("MetadataJson") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ReadAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RecipientEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RecipientUserId") + .HasColumnType("uuid"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ContentItemId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("RecipientUserId"); + + b.HasIndex("WorkspaceId"); + + b.ToTable("NotificationEvents", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Organizations.Data.Organization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("LogoUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("MembershipTierId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValue(new Guid("20000000-0000-0000-0000-000000000001")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OwnerUserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("MembershipTierId"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Organizations", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Organizations.Data.OrganizationMembership", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.HasIndex("OrganizationId", "UserId") + .IsUnique(); + + b.ToTable("OrganizationMemberships", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Organizations.Data.OrganizationMembershipTier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActiveContentLimit") + .HasColumnType("integer"); + + b.Property("ExternalReviewerLimit") + .HasColumnType("integer"); + + b.Property("IsCustom") + .HasColumnType("boolean"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MemberLimit") + .HasColumnType("integer"); + + b.Property("MonthlyPriceCents") + .HasColumnType("integer"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("WorkspaceLimit") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.HasIndex("SortOrder"); + + b.ToTable("OrganizationMembershipTiers", (string)null); + + b.HasData( + new + { + Id = new Guid("20000000-0000-0000-0000-000000000001"), + ActiveContentLimit = 3, + ExternalReviewerLimit = 1, + IsCustom = false, + Key = "free", + MemberLimit = 2, + MonthlyPriceCents = 0, + SortOrder = 10, + WorkspaceLimit = 1 + }, + new + { + Id = new Guid("20000000-0000-0000-0000-000000000002"), + ActiveContentLimit = 25, + ExternalReviewerLimit = 10, + IsCustom = false, + Key = "freelance", + MemberLimit = 5, + MonthlyPriceCents = 1900, + SortOrder = 20, + WorkspaceLimit = 3 + }, + new + { + Id = new Guid("20000000-0000-0000-0000-000000000003"), + ActiveContentLimit = 250, + IsCustom = false, + Key = "agency", + MemberLimit = 25, + MonthlyPriceCents = 7900, + SortOrder = 30, + WorkspaceLimit = 15 + }, + new + { + Id = new Guid("20000000-0000-0000-0000-000000000004"), + IsCustom = true, + Key = "enterprise", + SortOrder = 40 + }); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Organizations.Data.OrganizationMembershipTierTranslation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("MembershipTierId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("MembershipTierId", "Culture") + .IsUnique(); + + b.ToTable("OrganizationMembershipTierTranslations", (string)null); + + b.HasData( + new + { + Id = new Guid("20000000-0000-0001-0000-000000000001"), + Culture = "en", + Description = "For trying Socialize on one real approval workflow.", + MembershipTierId = new Guid("20000000-0000-0000-0000-000000000001"), + Name = "Free" + }, + new + { + Id = new Guid("20000000-0000-0001-0000-000000000002"), + Culture = "fr", + Description = "Pour essayer Socialize sur un vrai workflow d'approbation.", + MembershipTierId = new Guid("20000000-0000-0000-0000-000000000001"), + Name = "Free" + }, + new + { + Id = new Guid("20000000-0000-0001-0000-000000000003"), + Culture = "en", + Description = "For solo operators managing recurring client reviews.", + MembershipTierId = new Guid("20000000-0000-0000-0000-000000000002"), + Name = "Freelance" + }, + new + { + Id = new Guid("20000000-0000-0001-0000-000000000004"), + Culture = "fr", + Description = "Pour les independants qui gerent des revisions client recurrentes.", + MembershipTierId = new Guid("20000000-0000-0000-0000-000000000002"), + Name = "Freelance" + }, + new + { + Id = new Guid("20000000-0000-0001-0000-000000000005"), + Culture = "en", + Description = "For agencies that need repeatable client approval operations.", + MembershipTierId = new Guid("20000000-0000-0000-0000-000000000003"), + Name = "Agency" + }, + new + { + Id = new Guid("20000000-0000-0001-0000-000000000006"), + Culture = "fr", + Description = "Pour les agences qui veulent des operations d'approbation client repetables.", + MembershipTierId = new Guid("20000000-0000-0000-0000-000000000003"), + Name = "Agency" + }, + new + { + Id = new Guid("20000000-0000-0001-0000-000000000007"), + Culture = "en", + Description = "For larger organizations with governance and access needs.", + MembershipTierId = new Guid("20000000-0000-0000-0000-000000000004"), + Name = "Enterprise" + }, + new + { + Id = new Guid("20000000-0000-0001-0000-000000000008"), + Culture = "fr", + Description = "Pour les grandes organisations avec des besoins de gouvernance et d'acces.", + MembershipTierId = new Guid("20000000-0000-0000-0000-000000000004"), + Name = "Enterprise" + }); + }); + + modelBuilder.Entity("Socialize.Api.Modules.ReleaseCommunications.Data.ReleaseCommit", b => + { + b.Property("Sha") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("AuthorEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("AuthorName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("AuthoredAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CommittedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CommunicationStatus") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("DeploymentLabel") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ExternalUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("ImportedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("ReleaseUpdateId") + .HasColumnType("uuid"); + + b.Property("ShortSha") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("SourceBranch") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ArchivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Audience") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Body") + .HasMaxLength(8000) + .HasColumnType("character varying(8000)"); + + b.Property("BuildVersion") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CommitRange") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatedByUserId") + .HasColumnType("uuid"); + + b.Property("DeploymentLabel") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Importance") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ManualEmailAudience") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ManualEmailRecipientCount") + .HasColumnType("integer"); + + b.Property("ManualEmailSentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ManualEmailSentByUserId") + .HasColumnType("uuid"); + + b.Property("PublishedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("SentAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("UpdateCount") + .HasColumnType("integer"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ReadAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("ReleaseUpdateId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("ReleaseUpdateId", "UserId") + .IsUnique(); + + b.ToTable("ReleaseUpdateReadReceipts", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Workspaces.Data.Workspace", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovalMode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasDefaultValue("Required"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("LockContentAfterApproval") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("LogoUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("OwnerUserId") + .HasColumnType("uuid"); + + b.Property("SchedulePostsAutomaticallyOnApproval") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("SendAutomaticApprovalReminders") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("TimeZone") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Workspaces", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Workspaces.Data.WorkspaceInvite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("InvitedByUserId") + .HasColumnType("uuid"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorkspaceId"); + + b.HasIndex("WorkspaceId", "Email", "Status"); + + b.ToTable("WorkspaceInvites", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Socialize.Api.Modules.Identity.Data.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Socialize.Api.Modules.Identity.Data.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Socialize.Api.Modules.Identity.Data.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Socialize.Api.Modules.Identity.Data.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Socialize.Api.Modules.Identity.Data.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Socialize.Api.Modules.Identity.Data.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Approvals.Data.ApprovalDecision", b => + { + b.HasOne("Socialize.Api.Modules.Approvals.Data.ApprovalRequest", null) + .WithMany() + .HasForeignKey("ApprovalRequestId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Approvals.Data.ApprovalRequest", b => + { + b.HasOne("Socialize.Api.Modules.ContentItems.Data.ContentItem", null) + .WithMany() + .HasForeignKey("ContentItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Socialize.Api.Modules.Approvals.Data.ApprovalWorkflowInstance", null) + .WithMany() + .HasForeignKey("WorkflowInstanceId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Socialize.Api.Modules.Workspaces.Data.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Approvals.Data.ApprovalWorkflowInstance", b => + { + b.HasOne("Socialize.Api.Modules.ContentItems.Data.ContentItem", null) + .WithMany() + .HasForeignKey("ContentItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Socialize.Api.Modules.Workspaces.Data.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Approvals.Data.WorkspaceApprovalStepConfiguration", b => + { + b.HasOne("Socialize.Api.Modules.Workspaces.Data.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Assets.Data.Asset", b => + { + b.HasOne("Socialize.Api.Modules.ContentItems.Data.ContentItem", null) + .WithMany() + .HasForeignKey("ContentItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Socialize.Api.Modules.Workspaces.Data.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Assets.Data.AssetRevision", b => + { + b.HasOne("Socialize.Api.Modules.Assets.Data.Asset", null) + .WithMany() + .HasForeignKey("AssetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.CalendarIntegrations.Data.CalendarEvent", b => + { + b.HasOne("Socialize.Api.Modules.CalendarIntegrations.Data.CalendarSource", null) + .WithMany() + .HasForeignKey("CalendarSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Campaigns.Data.Campaign", b => + { + b.HasOne("Socialize.Api.Modules.Clients.Data.Client", null) + .WithMany() + .HasForeignKey("ClientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Socialize.Api.Modules.Workspaces.Data.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Channels.Data.Channel", b => + { + b.HasOne("Socialize.Api.Modules.Workspaces.Data.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Clients.Data.Client", b => + { + b.HasOne("Socialize.Api.Modules.Workspaces.Data.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Comments.Data.Comment", b => + { + b.HasOne("Socialize.Api.Modules.ContentItems.Data.ContentItem", null) + .WithMany() + .HasForeignKey("ContentItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Socialize.Api.Modules.Comments.Data.Comment", null) + .WithMany() + .HasForeignKey("ParentCommentId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Socialize.Api.Modules.Workspaces.Data.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.ContentItems.Data.ContentItem", b => + { + b.HasOne("Socialize.Api.Modules.Campaigns.Data.Campaign", null) + .WithMany() + .HasForeignKey("CampaignId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Socialize.Api.Modules.Clients.Data.Client", null) + .WithMany() + .HasForeignKey("ClientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Socialize.Api.Modules.Workspaces.Data.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.ContentItems.Data.ContentItemActivityEntry", b => + { + b.HasOne("Socialize.Api.Modules.ContentItems.Data.ContentItem", null) + .WithMany() + .HasForeignKey("ContentItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Socialize.Api.Modules.Workspaces.Data.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.ContentItems.Data.ContentItemRevision", b => + { + b.HasOne("Socialize.Api.Modules.ContentItems.Data.ContentItem", null) + .WithMany() + .HasForeignKey("ContentItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackActivityEntry", b => + { + b.HasOne("Socialize.Api.Modules.Feedback.Data.FeedbackReport", "FeedbackReport") + .WithMany("ActivityEntries") + .HasForeignKey("FeedbackReportId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeedbackReport"); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackComment", b => + { + b.HasOne("Socialize.Api.Modules.Feedback.Data.FeedbackReport", "FeedbackReport") + .WithMany("Comments") + .HasForeignKey("FeedbackReportId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeedbackReport"); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackReport", b => + { + b.HasOne("Socialize.Api.Modules.Campaigns.Data.Campaign", null) + .WithMany() + .HasForeignKey("CampaignId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Socialize.Api.Modules.Clients.Data.Client", null) + .WithMany() + .HasForeignKey("ClientId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Socialize.Api.Modules.ContentItems.Data.ContentItem", null) + .WithMany() + .HasForeignKey("ContentItemId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Socialize.Api.Modules.Workspaces.Data.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackScreenshot", b => + { + b.HasOne("Socialize.Api.Modules.Feedback.Data.FeedbackReport", "FeedbackReport") + .WithOne("Screenshot") + .HasForeignKey("Socialize.Api.Modules.Feedback.Data.FeedbackScreenshot", "FeedbackReportId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeedbackReport"); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackTag", b => + { + b.HasOne("Socialize.Api.Modules.Feedback.Data.FeedbackReport", "FeedbackReport") + .WithMany("Tags") + .HasForeignKey("FeedbackReportId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeedbackReport"); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Notifications.Data.NotificationEvent", b => + { + b.HasOne("Socialize.Api.Modules.ContentItems.Data.ContentItem", null) + .WithMany() + .HasForeignKey("ContentItemId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Socialize.Api.Modules.Workspaces.Data.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Organizations.Data.Organization", b => + { + b.HasOne("Socialize.Api.Modules.Organizations.Data.OrganizationMembershipTier", null) + .WithMany() + .HasForeignKey("MembershipTierId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Organizations.Data.OrganizationMembership", b => + { + b.HasOne("Socialize.Api.Modules.Organizations.Data.Organization", null) + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Organizations.Data.OrganizationMembershipTierTranslation", b => + { + b.HasOne("Socialize.Api.Modules.Organizations.Data.OrganizationMembershipTier", null) + .WithMany() + .HasForeignKey("MembershipTierId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.ReleaseCommunications.Data.ReleaseCommit", b => + { + b.HasOne("Socialize.Api.Modules.ReleaseCommunications.Data.ReleaseUpdate", "ReleaseUpdate") + .WithMany() + .HasForeignKey("ReleaseUpdateId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("ReleaseUpdate"); + }); + + modelBuilder.Entity("Socialize.Api.Modules.ReleaseCommunications.Data.ReleaseUpdateReadReceipt", b => + { + b.HasOne("Socialize.Api.Modules.ReleaseCommunications.Data.ReleaseUpdate", "ReleaseUpdate") + .WithMany("ReadReceipts") + .HasForeignKey("ReleaseUpdateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ReleaseUpdate"); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Workspaces.Data.Workspace", b => + { + b.HasOne("Socialize.Api.Modules.Organizations.Data.Organization", null) + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Workspaces.Data.WorkspaceInvite", b => + { + b.HasOne("Socialize.Api.Modules.Workspaces.Data.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackReport", b => + { + b.Navigation("ActivityEntries"); + + b.Navigation("Comments"); + + b.Navigation("Screenshot"); + + b.Navigation("Tags"); + }); + + modelBuilder.Entity("Socialize.Api.Modules.ReleaseCommunications.Data.ReleaseUpdate", b => + { + b.Navigation("ReadReceipts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Socialize.Api/Migrations/20260508010206_AddReleaseCommunications.cs b/backend/src/Socialize.Api/Migrations/20260508010206_AddReleaseCommunications.cs new file mode 100644 index 00000000..1b109b8f --- /dev/null +++ b/backend/src/Socialize.Api/Migrations/20260508010206_AddReleaseCommunications.cs @@ -0,0 +1,197 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Socialize.Api.Migrations +{ + /// + internal partial class AddReleaseCommunications : Migration + { + private static readonly string[] ReleaseUpdateReadReceiptUniqueIndexColumns = + [ + "ReleaseUpdateId", + "UserId", + ]; + + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "LastAuthenticatedAt", + table: "AspNetUsers", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.CreateTable( + name: "ReleaseUpdateEmailDigestReceipts", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + SentAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), + UpdateCount = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ReleaseUpdateEmailDigestReceipts", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "ReleaseUpdates", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Title = table.Column(type: "character varying(160)", maxLength: 160, nullable: false), + Summary = table.Column(type: "character varying(512)", maxLength: 512, nullable: false), + Body = table.Column(type: "character varying(8000)", maxLength: 8000, nullable: true), + Category = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + Importance = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + Audience = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + Status = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + DeploymentLabel = table.Column(type: "character varying(128)", maxLength: 128, nullable: true), + BuildVersion = table.Column(type: "character varying(128)", maxLength: 128, nullable: true), + CommitRange = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + CreatedByUserId = table.Column(type: "uuid", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false), + PublishedAt = table.Column(type: "timestamp with time zone", nullable: true), + ArchivedAt = table.Column(type: "timestamp with time zone", nullable: true), + ManualEmailSentByUserId = table.Column(type: "uuid", nullable: true), + ManualEmailSentAt = table.Column(type: "timestamp with time zone", nullable: true), + ManualEmailAudience = table.Column(type: "character varying(64)", maxLength: 64, nullable: true), + ManualEmailRecipientCount = table.Column(type: "integer", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ReleaseUpdates", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "ReleaseCommits", + columns: table => new + { + Sha = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + ShortSha = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + Subject = table.Column(type: "character varying(512)", maxLength: 512, nullable: false), + AuthorName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + AuthorEmail = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + AuthoredAt = table.Column(type: "timestamp with time zone", nullable: true), + CommittedAt = table.Column(type: "timestamp with time zone", nullable: true), + SourceBranch = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + DeploymentLabel = table.Column(type: "character varying(128)", maxLength: 128, nullable: true), + ExternalUrl = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: true), + CommunicationStatus = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + ReleaseUpdateId = table.Column(type: "uuid", nullable: true), + ImportedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), + UpdatedAt = table.Column(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(type: "uuid", nullable: false), + ReleaseUpdateId = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + ReadAt = table.Column(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"); + } + + /// + 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"); + } + } +} diff --git a/backend/src/Socialize.Api/Migrations/AppDbContextModelSnapshot.cs b/backend/src/Socialize.Api/Migrations/AppDbContextModelSnapshot.cs index d8497086..2182df43 100644 --- a/backend/src/Socialize.Api/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/Socialize.Api/Migrations/AppDbContextModelSnapshot.cs @@ -1522,6 +1522,9 @@ namespace Socialize.Api.Migrations .HasMaxLength(256) .HasColumnType("character varying(256)"); + b.Property("LastAuthenticatedAt") + .HasColumnType("timestamp with time zone"); + b.Property("Lastname") .HasMaxLength(256) .HasColumnType("character varying(256)"); @@ -1899,6 +1902,223 @@ namespace Socialize.Api.Migrations }); }); + modelBuilder.Entity("Socialize.Api.Modules.ReleaseCommunications.Data.ReleaseCommit", b => + { + b.Property("Sha") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("AuthorEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("AuthorName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("AuthoredAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CommittedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CommunicationStatus") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("DeploymentLabel") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ExternalUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("ImportedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("ReleaseUpdateId") + .HasColumnType("uuid"); + + b.Property("ShortSha") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("SourceBranch") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ArchivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Audience") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Body") + .HasMaxLength(8000) + .HasColumnType("character varying(8000)"); + + b.Property("BuildVersion") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CommitRange") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatedByUserId") + .HasColumnType("uuid"); + + b.Property("DeploymentLabel") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Importance") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ManualEmailAudience") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ManualEmailRecipientCount") + .HasColumnType("integer"); + + b.Property("ManualEmailSentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ManualEmailSentByUserId") + .HasColumnType("uuid"); + + b.Property("PublishedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("SentAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("UpdateCount") + .HasColumnType("integer"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ReadAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("ReleaseUpdateId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("ReleaseUpdateId", "UserId") + .IsUnique(); + + b.ToTable("ReleaseUpdateReadReceipts", (string)null); + }); + modelBuilder.Entity("Socialize.Api.Modules.Workspaces.Data.Workspace", b => { b.Property("Id") @@ -2345,6 +2565,27 @@ namespace Socialize.Api.Migrations .IsRequired(); }); + modelBuilder.Entity("Socialize.Api.Modules.ReleaseCommunications.Data.ReleaseCommit", b => + { + b.HasOne("Socialize.Api.Modules.ReleaseCommunications.Data.ReleaseUpdate", "ReleaseUpdate") + .WithMany() + .HasForeignKey("ReleaseUpdateId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("ReleaseUpdate"); + }); + + modelBuilder.Entity("Socialize.Api.Modules.ReleaseCommunications.Data.ReleaseUpdateReadReceipt", b => + { + b.HasOne("Socialize.Api.Modules.ReleaseCommunications.Data.ReleaseUpdate", "ReleaseUpdate") + .WithMany("ReadReceipts") + .HasForeignKey("ReleaseUpdateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ReleaseUpdate"); + }); + modelBuilder.Entity("Socialize.Api.Modules.Workspaces.Data.Workspace", b => { b.HasOne("Socialize.Api.Modules.Organizations.Data.Organization", null) @@ -2373,6 +2614,11 @@ namespace Socialize.Api.Migrations b.Navigation("Tags"); }); + + modelBuilder.Entity("Socialize.Api.Modules.ReleaseCommunications.Data.ReleaseUpdate", b => + { + b.Navigation("ReadReceipts"); + }); #pragma warning restore 612, 618 } } diff --git a/backend/src/Socialize.Api/Modules/Identity/Data/User.cs b/backend/src/Socialize.Api/Modules/Identity/Data/User.cs index 8556fe48..05f445ce 100644 --- a/backend/src/Socialize.Api/Modules/Identity/Data/User.cs +++ b/backend/src/Socialize.Api/Modules/Identity/Data/User.cs @@ -15,5 +15,6 @@ internal class User : IdentityUser [MaxLength(256)] public string? FacebookId { get; set; } [MaxLength(44)] public string? RefreshToken { get; set; } public DateTime RefreshTokenExpiryTime { get; set; } + public DateTimeOffset? LastAuthenticatedAt { get; set; } public string Fullname => $"{Lastname}, {Firstname}"; } diff --git a/backend/src/Socialize.Api/Modules/Identity/Handlers/Login.cs b/backend/src/Socialize.Api/Modules/Identity/Handlers/Login.cs index 194bdacb..e4ca22e0 100644 --- a/backend/src/Socialize.Api/Modules/Identity/Handlers/Login.cs +++ b/backend/src/Socialize.Api/Modules/Identity/Handlers/Login.cs @@ -71,6 +71,7 @@ internal class LoginHandler( // Generate a new refresh token user.RefreshToken = RefreshTokenGenerator.Next(); user.RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime); + user.LastAuthenticatedAt = DateTimeOffset.UtcNow; await userManager.UpdateAsync(user); // Generate JWT token diff --git a/backend/src/Socialize.Api/Modules/Identity/Handlers/LoginWithFacebook.cs b/backend/src/Socialize.Api/Modules/Identity/Handlers/LoginWithFacebook.cs index 4a5751df..c9019fac 100644 --- a/backend/src/Socialize.Api/Modules/Identity/Handlers/LoginWithFacebook.cs +++ b/backend/src/Socialize.Api/Modules/Identity/Handlers/LoginWithFacebook.cs @@ -99,7 +99,8 @@ internal class LoginWithFacebookHandler( Lastname = userInfo.Name.Split(' ').Skip(1).FirstOrDefault() ?? "", Alias = userInfo.Name, PortraitUrl = userInfo.Picture.Picture.Url, - FacebookId = userInfo.Id // Storing Facebook ID + FacebookId = userInfo.Id, // Storing Facebook ID + LastAuthenticatedAt = DateTimeOffset.UtcNow, }; IdentityResult result = await userManager.CreateAsync( @@ -124,6 +125,7 @@ internal class LoginWithFacebookHandler( // Store refresh token in user's properties user.RefreshToken = refreshToken; user.RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime); + user.LastAuthenticatedAt = DateTimeOffset.UtcNow; await userManager.UpdateAsync(user); string accessToken = await accessTokenFactory.CreateAsync(user); diff --git a/backend/src/Socialize.Api/Modules/Identity/Handlers/LoginWithGoogle.cs b/backend/src/Socialize.Api/Modules/Identity/Handlers/LoginWithGoogle.cs index 6aa0b39b..b49d21fe 100644 --- a/backend/src/Socialize.Api/Modules/Identity/Handlers/LoginWithGoogle.cs +++ b/backend/src/Socialize.Api/Modules/Identity/Handlers/LoginWithGoogle.cs @@ -106,7 +106,8 @@ internal class LoginWithGoogleHandler( PortraitUrl = userInfo.Picture, GoogleId = userInfo.Id, RefreshToken = refreshToken, - RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime) + RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime), + LastAuthenticatedAt = DateTimeOffset.UtcNow, }; IdentityResult result = await userManager.CreateAsync( @@ -128,6 +129,7 @@ internal class LoginWithGoogleHandler( // Generate the new refresh token user.RefreshToken = RefreshTokenGenerator.Next(); user.RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime); + user.LastAuthenticatedAt = DateTimeOffset.UtcNow; await userManager.UpdateAsync(user); string accessToken = await accessTokenFactory.CreateAsync(user); diff --git a/backend/src/Socialize.Api/Modules/Identity/Handlers/RefreshToken.cs b/backend/src/Socialize.Api/Modules/Identity/Handlers/RefreshToken.cs index 4026ca99..4930528c 100644 --- a/backend/src/Socialize.Api/Modules/Identity/Handlers/RefreshToken.cs +++ b/backend/src/Socialize.Api/Modules/Identity/Handlers/RefreshToken.cs @@ -53,6 +53,7 @@ internal class RefreshTokenHandler( // Update refresh token expiry time user.RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime); + user.LastAuthenticatedAt = DateTimeOffset.UtcNow; await userManager.UpdateAsync(user); // Generate a new access token diff --git a/backend/src/Socialize.Api/Modules/ReleaseCommunications/Configuration/ReleaseCommunicationEmailOptions.cs b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Configuration/ReleaseCommunicationEmailOptions.cs new file mode 100644 index 00000000..80d0826b --- /dev/null +++ b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Configuration/ReleaseCommunicationEmailOptions.cs @@ -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; +} diff --git a/backend/src/Socialize.Api/Modules/ReleaseCommunications/Configuration/ReleaseCommunicationRepositoryOptions.cs b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Configuration/ReleaseCommunicationRepositoryOptions.cs new file mode 100644 index 00000000..6cafa8a0 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Configuration/ReleaseCommunicationRepositoryOptions.cs @@ -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; } +} diff --git a/backend/src/Socialize.Api/Modules/ReleaseCommunications/Contracts/ReleaseUpdateDtos.cs b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Contracts/ReleaseUpdateDtos.cs new file mode 100644 index 00000000..9fcb08f3 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Contracts/ReleaseUpdateDtos.cs @@ -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 Commits); + +internal record ReleaseUpdateEmailSendResultDto( + int RecipientCount, + DateTimeOffset SentAt, + bool TestMode); + +internal record ReleaseUpdateUnreadSummaryDto( + int UnreadCount, + int ImportantUnreadCount, + IReadOnlyCollection 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(); + } +} diff --git a/backend/src/Socialize.Api/Modules/ReleaseCommunications/Data/ReleaseCommit.cs b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Data/ReleaseCommit.cs new file mode 100644 index 00000000..9107e7e7 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Data/ReleaseCommit.cs @@ -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; } +} diff --git a/backend/src/Socialize.Api/Modules/ReleaseCommunications/Data/ReleaseCommitCommunicationStatus.cs b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Data/ReleaseCommitCommunicationStatus.cs new file mode 100644 index 00000000..f71ee250 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Data/ReleaseCommitCommunicationStatus.cs @@ -0,0 +1,9 @@ +namespace Socialize.Api.Modules.ReleaseCommunications.Data; + +internal enum ReleaseCommitCommunicationStatus +{ + Unreviewed, + Linked, + InternalOnly, + Ignored, +} diff --git a/backend/src/Socialize.Api/Modules/ReleaseCommunications/Data/ReleaseCommunicationsModelConfiguration.cs b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Data/ReleaseCommunicationsModelConfiguration.cs new file mode 100644 index 00000000..f6ca0d65 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Data/ReleaseCommunicationsModelConfiguration.cs @@ -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.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().HasMaxLength(32).IsRequired(); + releaseUpdate.Property(x => x.Importance).HasConversion().HasMaxLength(32).IsRequired(); + releaseUpdate.Property(x => x.Audience).HasConversion().HasMaxLength(32).IsRequired(); + releaseUpdate.Property(x => x.Status).HasConversion().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(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(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().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(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; + } +} diff --git a/backend/src/Socialize.Api/Modules/ReleaseCommunications/Data/ReleaseUpdate.cs b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Data/ReleaseUpdate.cs new file mode 100644 index 00000000..0314ff52 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Data/ReleaseUpdate.cs @@ -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 ReadReceipts { get; } = new List(); +} diff --git a/backend/src/Socialize.Api/Modules/ReleaseCommunications/Data/ReleaseUpdateAudience.cs b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Data/ReleaseUpdateAudience.cs new file mode 100644 index 00000000..e65f7427 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Data/ReleaseUpdateAudience.cs @@ -0,0 +1,8 @@ +namespace Socialize.Api.Modules.ReleaseCommunications.Data; + +internal enum ReleaseUpdateAudience +{ + Everyone, + OrganizationOwners, + Developers, +} diff --git a/backend/src/Socialize.Api/Modules/ReleaseCommunications/Data/ReleaseUpdateCategory.cs b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Data/ReleaseUpdateCategory.cs new file mode 100644 index 00000000..9f4bc845 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Data/ReleaseUpdateCategory.cs @@ -0,0 +1,9 @@ +namespace Socialize.Api.Modules.ReleaseCommunications.Data; + +internal enum ReleaseUpdateCategory +{ + Feature, + Improvement, + Fix, + BreakingChange, +} diff --git a/backend/src/Socialize.Api/Modules/ReleaseCommunications/Data/ReleaseUpdateEmailDigestReceipt.cs b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Data/ReleaseUpdateEmailDigestReceipt.cs new file mode 100644 index 00000000..1e6ec1de --- /dev/null +++ b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Data/ReleaseUpdateEmailDigestReceipt.cs @@ -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; } +} diff --git a/backend/src/Socialize.Api/Modules/ReleaseCommunications/Data/ReleaseUpdateImportance.cs b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Data/ReleaseUpdateImportance.cs new file mode 100644 index 00000000..3fb8ded4 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Data/ReleaseUpdateImportance.cs @@ -0,0 +1,7 @@ +namespace Socialize.Api.Modules.ReleaseCommunications.Data; + +internal enum ReleaseUpdateImportance +{ + Normal, + Important, +} diff --git a/backend/src/Socialize.Api/Modules/ReleaseCommunications/Data/ReleaseUpdateReadReceipt.cs b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Data/ReleaseUpdateReadReceipt.cs new file mode 100644 index 00000000..b3dc0c33 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Data/ReleaseUpdateReadReceipt.cs @@ -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!; +} diff --git a/backend/src/Socialize.Api/Modules/ReleaseCommunications/Data/ReleaseUpdateStatus.cs b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Data/ReleaseUpdateStatus.cs new file mode 100644 index 00000000..26c729b6 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Data/ReleaseUpdateStatus.cs @@ -0,0 +1,8 @@ +namespace Socialize.Api.Modules.ReleaseCommunications.Data; + +internal enum ReleaseUpdateStatus +{ + Draft, + Published, + Archived, +} diff --git a/backend/src/Socialize.Api/Modules/ReleaseCommunications/Handlers/ArchiveDeveloperReleaseUpdate.cs b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Handlers/ArchiveDeveloperReleaseUpdate.cs new file mode 100644 index 00000000..1502b214 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Handlers/ArchiveDeveloperReleaseUpdate.cs @@ -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 +{ + 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("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); + } +} diff --git a/backend/src/Socialize.Api/Modules/ReleaseCommunications/Handlers/CreateDeveloperReleaseUpdate.cs b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Handlers/CreateDeveloperReleaseUpdate.cs new file mode 100644 index 00000000..2aacd303 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Handlers/CreateDeveloperReleaseUpdate.cs @@ -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 +{ + 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 +{ + 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; + } +} diff --git a/backend/src/Socialize.Api/Modules/ReleaseCommunications/Handlers/GetDeveloperReleaseUpdate.cs b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Handlers/GetDeveloperReleaseUpdate.cs new file mode 100644 index 00000000..ffc2bfa6 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Handlers/GetDeveloperReleaseUpdate.cs @@ -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 +{ + 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("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); + } +} diff --git a/backend/src/Socialize.Api/Modules/ReleaseCommunications/Handlers/GetUnreadReleaseUpdates.cs b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Handlers/GetUnreadReleaseUpdates.cs new file mode 100644 index 00000000..998e348a --- /dev/null +++ b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Handlers/GetUnreadReleaseUpdates.cs @@ -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 +{ + 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 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); + } +} diff --git a/backend/src/Socialize.Api/Modules/ReleaseCommunications/Handlers/ImportDeveloperReleaseCommits.cs b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Handlers/ImportDeveloperReleaseCommits.cs new file mode 100644 index 00000000..a722de7c --- /dev/null +++ b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Handlers/ImportDeveloperReleaseCommits.cs @@ -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? Commits); + +internal class ImportDeveloperReleaseCommitsHandler( + AppDbContext dbContext, + IOptionsSnapshot repositoryOptions) + : Endpoint +{ + 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 requestedCommits = request.Commits.Select(ToReleaseCommit).ToArray(); + + int imported = 0; + int updated = 0; + int skipped = 0; + List 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; + } +} diff --git a/backend/src/Socialize.Api/Modules/ReleaseCommunications/Handlers/ListDeveloperReleaseCommits.cs b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Handlers/ListDeveloperReleaseCommits.cs new file mode 100644 index 00000000..61551ed8 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Handlers/ListDeveloperReleaseCommits.cs @@ -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> +{ + 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 commits = await dbContext.ReleaseCommits + .OrderByDescending(commit => commit.CommittedAt ?? commit.ImportedAt) + .Select(commit => commit.ToDto()) + .ToListAsync(ct); + + await SendOkAsync(commits, ct); + } +} diff --git a/backend/src/Socialize.Api/Modules/ReleaseCommunications/Handlers/ListDeveloperReleaseUpdates.cs b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Handlers/ListDeveloperReleaseUpdates.cs new file mode 100644 index 00000000..281d0568 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Handlers/ListDeveloperReleaseUpdates.cs @@ -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> +{ + 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 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); + } +} diff --git a/backend/src/Socialize.Api/Modules/ReleaseCommunications/Handlers/ListReleaseUpdates.cs b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Handlers/ListReleaseUpdates.cs new file mode 100644 index 00000000..bd95f681 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Handlers/ListReleaseUpdates.cs @@ -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> +{ + 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 updates = await dbContext.ReleaseUpdates + .VisibleTo(audienceContext) + .OrderByDescending(update => update.PublishedAt) + .ThenByDescending(update => update.CreatedAt) + .ToListAsync(ct); + + HashSet 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> GetReadUpdateIdsAsync( + Guid userId, + IEnumerable 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); + } +} diff --git a/backend/src/Socialize.Api/Modules/ReleaseCommunications/Handlers/MarkAllReleaseUpdatesRead.cs b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Handlers/MarkAllReleaseUpdatesRead.cs new file mode 100644 index 00000000..c3bc4451 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Handlers/MarkAllReleaseUpdatesRead.cs @@ -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 visibleUpdateIds = await dbContext.ReleaseUpdates + .VisibleTo(audienceContext) + .Select(update => update.Id) + .ToListAsync(ct); + + HashSet 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); + } +} diff --git a/backend/src/Socialize.Api/Modules/ReleaseCommunications/Handlers/MarkReleaseUpdateRead.cs b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Handlers/MarkReleaseUpdateRead.cs new file mode 100644 index 00000000..fed5dc27 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Handlers/MarkReleaseUpdateRead.cs @@ -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("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(), + DateTimeOffset.UtcNow)); + await dbContext.SaveChangesAsync(ct); + } + + await SendNoContentAsync(ct); + } +} diff --git a/backend/src/Socialize.Api/Modules/ReleaseCommunications/Handlers/PublishDeveloperReleaseUpdate.cs b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Handlers/PublishDeveloperReleaseUpdate.cs new file mode 100644 index 00000000..e9115601 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Handlers/PublishDeveloperReleaseUpdate.cs @@ -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 +{ + 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("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); + } +} diff --git a/backend/src/Socialize.Api/Modules/ReleaseCommunications/Handlers/SendDeveloperReleaseUpdateEmail.cs b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Handlers/SendDeveloperReleaseUpdateEmail.cs new file mode 100644 index 00000000..1695d7ab --- /dev/null +++ b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Handlers/SendDeveloperReleaseUpdateEmail.cs @@ -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 +{ + 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("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); + } + } +} diff --git a/backend/src/Socialize.Api/Modules/ReleaseCommunications/Handlers/UpdateDeveloperReleaseCommitStatus.cs b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Handlers/UpdateDeveloperReleaseCommitStatus.cs new file mode 100644 index 00000000..dc081dd4 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Handlers/UpdateDeveloperReleaseCommitStatus.cs @@ -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 +{ + protected AppDbContext DbContext => dbContext; + + protected async Task GetCommitAsync(CancellationToken ct) + { + string? sha = Route("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 +{ + 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("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); + } +} diff --git a/backend/src/Socialize.Api/Modules/ReleaseCommunications/Handlers/UpdateDeveloperReleaseUpdate.cs b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Handlers/UpdateDeveloperReleaseUpdate.cs new file mode 100644 index 00000000..3bfa2ae3 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Handlers/UpdateDeveloperReleaseUpdate.cs @@ -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 +{ + 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 +{ + 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("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; + } +} diff --git a/backend/src/Socialize.Api/Modules/ReleaseCommunications/ModuleRegistration.cs b/backend/src/Socialize.Api/Modules/ReleaseCommunications/ModuleRegistration.cs new file mode 100644 index 00000000..71ee72a6 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/ReleaseCommunications/ModuleRegistration.cs @@ -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( + builder.Configuration.GetSection(ReleaseCommunicationEmailOptions.SectionName)); + builder.Services.Configure( + builder.Configuration.GetSection(ReleaseCommunicationRepositoryOptions.SectionName)); + builder.Services.AddScoped(); + builder.Services.AddHostedService(); + + return builder; + } +} diff --git a/backend/src/Socialize.Api/Modules/ReleaseCommunications/Services/ReleaseUpdateEmailDigestBackgroundService.cs b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Services/ReleaseUpdateEmailDigestBackgroundService.cs new file mode 100644 index 00000000..dc19f55d --- /dev/null +++ b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Services/ReleaseUpdateEmailDigestBackgroundService.cs @@ -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 options, + ILogger 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(); + 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 + } +} diff --git a/backend/src/Socialize.Api/Modules/ReleaseCommunications/Services/ReleaseUpdateEmailRules.cs b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Services/ReleaseUpdateEmailRules.cs new file mode 100644 index 00000000..4e427489 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Services/ReleaseUpdateEmailRules.cs @@ -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; + } +} diff --git a/backend/src/Socialize.Api/Modules/ReleaseCommunications/Services/ReleaseUpdateEmailService.cs b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Services/ReleaseUpdateEmailService.cs new file mode 100644 index 00000000..219879d2 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Services/ReleaseUpdateEmailService.cs @@ -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) +{ + public async Task 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 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 SendDueDigestEmailsAsync( + TimeSpan inactiveThreshold, + TimeSpan sendInterval, + CancellationToken ct) + { + DateTimeOffset now = DateTimeOffset.UtcNow; + DateTimeOffset inactiveBefore = now.Subtract(inactiveThreshold); + DateTimeOffset lastSentBefore = now.Subtract(sendInterval); + + List 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 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> GetTestRecipientsAsync(Guid senderUserId, CancellationToken ct) + { + User? sender = await userManager.Users.SingleOrDefaultAsync(user => user.Id == senderUserId, ct); + return sender is null ? [] : [sender]; + } + + private async Task> GetAudienceRecipientsAsync(ReleaseUpdateAudience audience, CancellationToken ct) + { + IQueryable query = userManager.Users.Where(user => user.EmailConfirmed && user.Email != null); + + if (audience == ReleaseUpdateAudience.Developers) + { + IList 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 $""" +

{HtmlEncode(update.Title)}

+

{HtmlEncode(update.Category.ToString())}

+

{HtmlEncode(update.Summary)}

+ {FormatBody(update.Body)} +

Open What's New

+ """; + } + + private string BuildDigestEmail(IReadOnlyCollection updates) + { + string updateUrl = $"{websiteOptions.Value.FrontendBaseUrl.TrimEnd('/')}/app/updates"; + string listItems = string.Join( + Environment.NewLine, + updates.Select(update => $"
  • {HtmlEncode(update.Title)}
    {HtmlEncode(update.Summary)}
  • ")); + + return $""" +

    What's new in Socialize

    +
      {listItems}
    +

    Open What's New

    + """; + } + + private static string FormatBody(string? body) + { + return string.IsNullOrWhiteSpace(body) + ? string.Empty + : $"

    {HtmlEncode(body).Replace(Environment.NewLine, "
    ", StringComparison.Ordinal)}

    "; + } + + private static string HtmlEncode(string? value) + { + return WebUtility.HtmlEncode(value ?? string.Empty); + } +} diff --git a/backend/src/Socialize.Api/Modules/ReleaseCommunications/Services/ReleaseUpdateReadState.cs b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Services/ReleaseUpdateReadState.cs new file mode 100644 index 00000000..c1acfc50 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Services/ReleaseUpdateReadState.cs @@ -0,0 +1,24 @@ +using Socialize.Api.Modules.ReleaseCommunications.Data; + +namespace Socialize.Api.Modules.ReleaseCommunications.Services; + +internal static class ReleaseUpdateReadState +{ + public static IReadOnlyCollection CreateMissingReadReceipts( + Guid userId, + IEnumerable visibleUpdateIds, + ISet existingReadUpdateIds, + DateTimeOffset readAt) + { + return visibleUpdateIds + .Where(updateId => !existingReadUpdateIds.Contains(updateId)) + .Select(updateId => new ReleaseUpdateReadReceipt + { + Id = Guid.NewGuid(), + ReleaseUpdateId = updateId, + UserId = userId, + ReadAt = readAt, + }) + .ToArray(); + } +} diff --git a/backend/src/Socialize.Api/Modules/ReleaseCommunications/Services/ReleaseUpdateRules.cs b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Services/ReleaseUpdateRules.cs new file mode 100644 index 00000000..6ce69416 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Services/ReleaseUpdateRules.cs @@ -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(string value, out TEnum result) + where TEnum : struct + { + string normalized = value.Replace(" ", string.Empty, StringComparison.Ordinal); + return Enum.TryParse(normalized, ignoreCase: true, out result); + } +} diff --git a/backend/src/Socialize.Api/Modules/ReleaseCommunications/Services/ReleaseUpdateVisibility.cs b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Services/ReleaseUpdateVisibility.cs new file mode 100644 index 00000000..d66966af --- /dev/null +++ b/backend/src/Socialize.Api/Modules/ReleaseCommunications/Services/ReleaseUpdateVisibility.cs @@ -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 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 VisibleTo( + this IQueryable 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); diff --git a/backend/src/Socialize.Api/Program.cs b/backend/src/Socialize.Api/Program.cs index a53c173f..c9d927df 100644 --- a/backend/src/Socialize.Api/Program.cs +++ b/backend/src/Socialize.Api/Program.cs @@ -19,6 +19,7 @@ using Socialize.Api.Modules.Notifications; using Socialize.Api.Modules.Campaigns; using Socialize.Api.Modules.CalendarIntegrations; using Socialize.Api.Modules.Organizations; +using Socialize.Api.Modules.ReleaseCommunications; using Socialize.Api.Modules.Workspaces; @@ -78,6 +79,7 @@ builder.AddApprovalsModule(); builder.AddNotificationsModule(); builder.AddFeedbackModule(); builder.AddCalendarIntegrationsModule(); +builder.AddReleaseCommunicationsModule(); var app = builder.Build(); diff --git a/backend/tests/Socialize.Tests/ReleaseCommunications/ReleaseUpdateRulesTests.cs b/backend/tests/Socialize.Tests/ReleaseCommunications/ReleaseUpdateRulesTests.cs new file mode 100644 index 00000000..66e1a008 --- /dev/null +++ b/backend/tests/Socialize.Tests/ReleaseCommunications/ReleaseUpdateRulesTests.cs @@ -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 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 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 ownerVisibleUpdates = new[] { ownerUpdate, developerUpdate } + .AsQueryable() + .VisibleTo(new ReleaseUpdateAudienceContext(IsDeveloper: false, IsOrganizationOwner: true)) + .ToList(); + + List 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 receipts = + ReleaseUpdateReadState.CreateMissingReadReceipts( + userId, + [unreadUpdateId, readUpdateId], + new HashSet { 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(), + }; + } +} diff --git a/docs/FEATURES/release-communications.md b/docs/FEATURES/release-communications.md new file mode 100644 index 00000000..f4e3c471 --- /dev/null +++ b/docs/FEATURES/release-communications.md @@ -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. diff --git a/docs/TASKS/release-communications/001-backend-release-update-foundation.md b/docs/TASKS/release-communications/001-backend-release-update-foundation.md new file mode 100644 index 00000000..943a1651 --- /dev/null +++ b/docs/TASKS/release-communications/001-backend-release-update-foundation.md @@ -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. diff --git a/docs/TASKS/release-communications/002-frontend-whats-new.md b/docs/TASKS/release-communications/002-frontend-whats-new.md new file mode 100644 index 00000000..ecb32d33 --- /dev/null +++ b/docs/TASKS/release-communications/002-frontend-whats-new.md @@ -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. diff --git a/docs/TASKS/release-communications/003-developer-commit-reconciliation.md b/docs/TASKS/release-communications/003-developer-commit-reconciliation.md new file mode 100644 index 00000000..0b6be445 --- /dev/null +++ b/docs/TASKS/release-communications/003-developer-commit-reconciliation.md @@ -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. diff --git a/docs/TASKS/release-communications/004-email-digest.md b/docs/TASKS/release-communications/004-email-digest.md new file mode 100644 index 00000000..50ffc6ef --- /dev/null +++ b/docs/TASKS/release-communications/004-email-digest.md @@ -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. diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index 56d995dd..e3473144 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -100,6 +100,246 @@ export interface paths { patch?: 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": { parameters: { query?: never; @@ -1227,6 +1467,131 @@ export interface components { /** Format: int32 */ 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: { /** Format: guid */ 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: { parameters: { query?: never; diff --git a/frontend/src/features/release-communications/stores/releaseCommunicationsStore.js b/frontend/src/features/release-communications/stores/releaseCommunicationsStore.js new file mode 100644 index 00000000..ef3b7220 --- /dev/null +++ b/frontend/src/features/release-communications/stores/releaseCommunicationsStore.js @@ -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, + }; +}); diff --git a/frontend/src/features/release-communications/views/DeveloperReleaseCommitsView.vue b/frontend/src/features/release-communications/views/DeveloperReleaseCommitsView.vue new file mode 100644 index 00000000..e70d1435 --- /dev/null +++ b/frontend/src/features/release-communications/views/DeveloperReleaseCommitsView.vue @@ -0,0 +1,155 @@ + + + + + diff --git a/frontend/src/features/release-communications/views/DeveloperUpdatesView.vue b/frontend/src/features/release-communications/views/DeveloperUpdatesView.vue new file mode 100644 index 00000000..90331f72 --- /dev/null +++ b/frontend/src/features/release-communications/views/DeveloperUpdatesView.vue @@ -0,0 +1,246 @@ + + + + + diff --git a/frontend/src/features/release-communications/views/UpdatesView.vue b/frontend/src/features/release-communications/views/UpdatesView.vue new file mode 100644 index 00000000..e0e34952 --- /dev/null +++ b/frontend/src/features/release-communications/views/UpdatesView.vue @@ -0,0 +1,167 @@ + + + + + diff --git a/frontend/src/layouts/main/AppSidebar.vue b/frontend/src/layouts/main/AppSidebar.vue index 57e0d88c..548e636d 100644 --- a/frontend/src/layouts/main/AppSidebar.vue +++ b/frontend/src/layouts/main/AppSidebar.vue @@ -5,6 +5,7 @@ import { useAuthStore } from '@/features/auth/stores/authStore.js'; import { useChannelsStore } from '@/features/channels/stores/channelsStore.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 { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js'; import { useCampaignsStore } from '@/features/campaigns/stores/campaignsStore.js'; @@ -22,6 +23,8 @@ mdiMagnify, mdiPlus, mdiBugOutline, + mdiBullhornOutline, + mdiSourceCommit, } from '@mdi/js'; const props = defineProps({ @@ -38,6 +41,7 @@ const channelsStore = useChannelsStore(); const contentItemsStore = useContentItemsStore(); const notificationsStore = useNotificationsStore(); + const releaseCommunicationsStore = useReleaseCommunicationsStore(); const campaignsStore = useCampaignsStore(); const isNotificationsOpen = ref(false); const isSearchFocused = ref(false); @@ -51,7 +55,10 @@ const primaryLinks = [ { to: '/app/dashboard', labelKey: 'nav.overview', icon: mdiHomeOutline }, { 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/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(() => primaryLinks.filter(link => !link.roles || authStore.hasAnyRole(link.roles)) @@ -231,6 +238,14 @@ ); 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); window.addEventListener('resize', updateCollapsedSearchPanelPosition); window.addEventListener('scroll', updateCollapsedSearchPanelPosition, true); @@ -450,7 +465,21 @@ active-class="sidebar-link-active" :title="!isExpanded ? t(link.labelKey) : null" > - + + + + {{ Math.min(releaseCommunicationsStore.unreadCount, 9) }} + + + {{ Math.min(releaseCommunicationsStore.unreviewedCommitCount, 9) }} + + import('@/features/feedback/views/MyFeedbackLis const MyFeedbackDetailView = () => import('@/features/feedback/views/MyFeedbackDetailView.vue'); const DeveloperFeedbackListView = () => import('@/features/feedback/views/DeveloperFeedbackListView.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 = [ { @@ -123,6 +126,24 @@ const routes = [ component: MyFeedbackDetailView, 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', name: 'developer-feedback', diff --git a/shared/openapi/openapi.json b/shared/openapi/openapi.json index e42c8691..0e7c6184 100644 --- a/shared/openapi/openapi.json +++ b/shared/openapi/openapi.json @@ -7,7 +7,7 @@ }, "servers": [ { - "url": "http://localhost:5081" + "url": "http://127.0.0.1:5080" } ], "paths": { @@ -385,6 +385,740 @@ ] } }, + "/api/developer/release-updates/{id}/archive": { + "post": { + "tags": [ + "Release Communications", + "Api" + ], + "operationId": "SocializeApiModulesReleaseCommunicationsHandlersArchiveDeveloperReleaseUpdateHandler", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateDto" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "JWTBearerAuth": [ + "developer" + ] + } + ] + } + }, + "/api/developer/release-updates": { + "post": { + "tags": [ + "Release Communications", + "Api" + ], + "operationId": "SocializeApiModulesReleaseCommunicationsHandlersCreateDeveloperReleaseUpdateHandler", + "requestBody": { + "x-name": "CreateDeveloperReleaseUpdateRequest", + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SocializeApiModulesReleaseCommunicationsHandlersCreateDeveloperReleaseUpdateRequest" + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateDto" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/FastEndpointsErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "JWTBearerAuth": [ + "developer" + ] + } + ] + }, + "get": { + "tags": [ + "Release Communications", + "Api" + ], + "operationId": "SocializeApiModulesReleaseCommunicationsHandlersListDeveloperReleaseUpdatesHandler", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateDto" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "JWTBearerAuth": [ + "developer" + ] + } + ] + } + }, + "/api/developer/release-updates/{id}": { + "get": { + "tags": [ + "Release Communications", + "Api" + ], + "operationId": "SocializeApiModulesReleaseCommunicationsHandlersGetDeveloperReleaseUpdateHandler", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateDto" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "JWTBearerAuth": [ + "developer" + ] + } + ] + }, + "put": { + "tags": [ + "Release Communications", + "Api" + ], + "operationId": "SocializeApiModulesReleaseCommunicationsHandlersUpdateDeveloperReleaseUpdateHandler", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "x-name": "UpdateDeveloperReleaseUpdateRequest", + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SocializeApiModulesReleaseCommunicationsHandlersUpdateDeveloperReleaseUpdateRequest" + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateDto" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/FastEndpointsErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "JWTBearerAuth": [ + "developer" + ] + } + ] + } + }, + "/api/release-updates/unread": { + "get": { + "tags": [ + "Release Communications", + "Api" + ], + "operationId": "SocializeApiModulesReleaseCommunicationsHandlersGetUnreadReleaseUpdatesHandler", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateUnreadSummaryDto" + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "JWTBearerAuth": [] + } + ] + } + }, + "/api/developer/release-commits/import": { + "post": { + "tags": [ + "Release Communications", + "Api" + ], + "operationId": "SocializeApiModulesReleaseCommunicationsHandlersImportDeveloperReleaseCommitsHandler", + "requestBody": { + "x-name": "ImportDeveloperReleaseCommitsRequest", + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SocializeApiModulesReleaseCommunicationsHandlersImportDeveloperReleaseCommitsRequest" + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SocializeApiModulesReleaseCommunicationsContractsReleaseCommitImportResultDto" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "JWTBearerAuth": [ + "developer" + ] + } + ] + } + }, + "/api/developer/release-commits": { + "get": { + "tags": [ + "Release Communications", + "Api" + ], + "operationId": "SocializeApiModulesReleaseCommunicationsHandlersListDeveloperReleaseCommitsHandler", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SocializeApiModulesReleaseCommunicationsContractsReleaseCommitDto" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "JWTBearerAuth": [ + "developer" + ] + } + ] + } + }, + "/api/release-updates": { + "get": { + "tags": [ + "Release Communications", + "Api" + ], + "operationId": "SocializeApiModulesReleaseCommunicationsHandlersListReleaseUpdatesHandler", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateDto" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "JWTBearerAuth": [] + } + ] + } + }, + "/api/release-updates/read-all": { + "post": { + "tags": [ + "Release Communications", + "Api" + ], + "operationId": "SocializeApiModulesReleaseCommunicationsHandlersMarkAllReleaseUpdatesReadHandler", + "responses": { + "204": { + "description": "No Content" + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "JWTBearerAuth": [] + } + ] + } + }, + "/api/release-updates/{id}/read": { + "post": { + "tags": [ + "Release Communications", + "Api" + ], + "operationId": "SocializeApiModulesReleaseCommunicationsHandlersMarkReleaseUpdateReadHandler", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "JWTBearerAuth": [] + } + ] + } + }, + "/api/developer/release-updates/{id}/publish": { + "post": { + "tags": [ + "Release Communications", + "Api" + ], + "operationId": "SocializeApiModulesReleaseCommunicationsHandlersPublishDeveloperReleaseUpdateHandler", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateDto" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "JWTBearerAuth": [ + "developer" + ] + } + ] + } + }, + "/api/developer/release-updates/{id}/send-email": { + "post": { + "tags": [ + "Release Communications", + "Api" + ], + "operationId": "SocializeApiModulesReleaseCommunicationsHandlersSendDeveloperReleaseUpdateEmailHandler", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "x-name": "SendDeveloperReleaseUpdateEmailRequest", + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SocializeApiModulesReleaseCommunicationsHandlersSendDeveloperReleaseUpdateEmailRequest" + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateEmailSendResultDto" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "JWTBearerAuth": [ + "developer" + ] + } + ] + } + }, + "/api/developer/release-commits/{sha}/link": { + "post": { + "tags": [ + "Release Communications", + "Api" + ], + "operationId": "SocializeApiModulesReleaseCommunicationsHandlersLinkDeveloperReleaseCommitHandler", + "parameters": [ + { + "name": "sha", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "x-name": "LinkDeveloperReleaseCommitRequest", + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SocializeApiModulesReleaseCommunicationsHandlersLinkDeveloperReleaseCommitRequest" + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SocializeApiModulesReleaseCommunicationsContractsReleaseCommitDto" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "JWTBearerAuth": [ + "developer" + ] + } + ] + } + }, + "/api/developer/release-commits/{sha}/unlink": { + "post": { + "tags": [ + "Release Communications", + "Api" + ], + "operationId": "SocializeApiModulesReleaseCommunicationsHandlersUnlinkDeveloperReleaseCommitHandler", + "parameters": [ + { + "name": "sha", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SocializeApiModulesReleaseCommunicationsContractsReleaseCommitDto" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "JWTBearerAuth": [ + "developer" + ] + } + ] + } + }, + "/api/developer/release-commits/{sha}/internal-only": { + "post": { + "tags": [ + "Release Communications", + "Api" + ], + "operationId": "SocializeApiModulesReleaseCommunicationsHandlersMarkDeveloperReleaseCommitInternalOnlyHandler", + "parameters": [ + { + "name": "sha", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SocializeApiModulesReleaseCommunicationsContractsReleaseCommitDto" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "JWTBearerAuth": [ + "developer" + ] + } + ] + } + }, + "/api/developer/release-commits/{sha}/ignore": { + "post": { + "tags": [ + "Release Communications", + "Api" + ], + "operationId": "SocializeApiModulesReleaseCommunicationsHandlersIgnoreDeveloperReleaseCommitHandler", + "parameters": [ + { + "name": "sha", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SocializeApiModulesReleaseCommunicationsContractsReleaseCommitDto" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "JWTBearerAuth": [ + "developer" + ] + } + ] + } + }, "/api/organizations/{organizationId}/members": { "post": { "tags": [ @@ -4201,6 +4935,443 @@ } } }, + "SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "format": "guid" + }, + "title": { + "type": "string" + }, + "summary": { + "type": "string" + }, + "body": { + "type": "string", + "nullable": true + }, + "category": { + "type": "string" + }, + "importance": { + "type": "string" + }, + "audience": { + "type": "string" + }, + "status": { + "type": "string" + }, + "deploymentLabel": { + "type": "string", + "nullable": true + }, + "buildVersion": { + "type": "string", + "nullable": true + }, + "commitRange": { + "type": "string", + "nullable": true + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "publishedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "archivedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "manualEmailSentByUserId": { + "type": "string", + "format": "guid", + "nullable": true + }, + "manualEmailSentAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "manualEmailAudience": { + "type": "string", + "nullable": true + }, + "manualEmailRecipientCount": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "isRead": { + "type": "boolean" + } + } + }, + "SocializeApiModulesReleaseCommunicationsHandlersCreateDeveloperReleaseUpdateRequest": { + "type": "object", + "additionalProperties": false, + "required": [ + "title", + "summary", + "category", + "importance", + "audience" + ], + "properties": { + "title": { + "type": "string", + "maxLength": 160, + "minLength": 0, + "nullable": false + }, + "summary": { + "type": "string", + "maxLength": 512, + "minLength": 0, + "nullable": false + }, + "body": { + "type": "string", + "maxLength": 8000, + "minLength": 0, + "nullable": true + }, + "category": { + "type": "string", + "maxLength": 32, + "minLength": 0, + "nullable": false + }, + "importance": { + "type": "string", + "maxLength": 32, + "minLength": 0, + "nullable": false + }, + "audience": { + "type": "string", + "maxLength": 32, + "minLength": 0, + "nullable": false + }, + "deploymentLabel": { + "type": "string", + "maxLength": 128, + "minLength": 0, + "nullable": true + }, + "buildVersion": { + "type": "string", + "maxLength": 128, + "minLength": 0, + "nullable": true + }, + "commitRange": { + "type": "string", + "maxLength": 256, + "minLength": 0, + "nullable": true + } + } + }, + "SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateUnreadSummaryDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "unreadCount": { + "type": "integer", + "format": "int32" + }, + "importantUnreadCount": { + "type": "integer", + "format": "int32" + }, + "updates": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateDto" + } + } + } + }, + "SocializeApiModulesReleaseCommunicationsContractsReleaseCommitImportResultDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "importedCount": { + "type": "integer", + "format": "int32" + }, + "updatedCount": { + "type": "integer", + "format": "int32" + }, + "skippedCount": { + "type": "integer", + "format": "int32" + }, + "commits": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SocializeApiModulesReleaseCommunicationsContractsReleaseCommitDto" + } + } + } + }, + "SocializeApiModulesReleaseCommunicationsContractsReleaseCommitDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "sha": { + "type": "string" + }, + "shortSha": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "authorName": { + "type": "string", + "nullable": true + }, + "authorEmail": { + "type": "string", + "nullable": true + }, + "authoredAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "committedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "sourceBranch": { + "type": "string", + "nullable": true + }, + "deploymentLabel": { + "type": "string", + "nullable": true + }, + "externalUrl": { + "type": "string", + "nullable": true + }, + "communicationStatus": { + "type": "string" + }, + "releaseUpdateId": { + "type": "string", + "format": "guid", + "nullable": true + }, + "importedAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "SocializeApiModulesReleaseCommunicationsHandlersImportDeveloperReleaseCommitsRequest": { + "type": "object", + "additionalProperties": false, + "properties": { + "sinceSha": { + "type": "string", + "nullable": true + }, + "untilSha": { + "type": "string", + "nullable": true + }, + "sourceBranch": { + "type": "string", + "nullable": true + }, + "deploymentLabel": { + "type": "string", + "nullable": true + }, + "commits": { + "type": "array", + "nullable": true, + "items": { + "$ref": "#/components/schemas/SocializeApiModulesReleaseCommunicationsHandlersImportDeveloperReleaseCommitDto" + } + } + } + }, + "SocializeApiModulesReleaseCommunicationsHandlersImportDeveloperReleaseCommitDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "sha": { + "type": "string" + }, + "shortSha": { + "type": "string", + "nullable": true + }, + "subject": { + "type": "string" + }, + "authorName": { + "type": "string", + "nullable": true + }, + "authorEmail": { + "type": "string", + "nullable": true + }, + "authoredAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "committedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "sourceBranch": { + "type": "string", + "nullable": true + }, + "deploymentLabel": { + "type": "string", + "nullable": true + }, + "externalUrl": { + "type": "string", + "nullable": true + } + } + }, + "SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateEmailSendResultDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "recipientCount": { + "type": "integer", + "format": "int32" + }, + "sentAt": { + "type": "string", + "format": "date-time" + }, + "testMode": { + "type": "boolean" + } + } + }, + "SocializeApiModulesReleaseCommunicationsHandlersSendDeveloperReleaseUpdateEmailRequest": { + "type": "object", + "additionalProperties": false, + "properties": { + "testMode": { + "type": "boolean" + }, + "confirmResend": { + "type": "boolean" + } + } + }, + "SocializeApiModulesReleaseCommunicationsHandlersLinkDeveloperReleaseCommitRequest": { + "type": "object", + "additionalProperties": false, + "properties": { + "releaseUpdateId": { + "type": "string", + "format": "guid" + } + } + }, + "SocializeApiModulesReleaseCommunicationsHandlersUpdateDeveloperReleaseUpdateRequest": { + "type": "object", + "additionalProperties": false, + "required": [ + "title", + "summary", + "category", + "importance", + "audience" + ], + "properties": { + "title": { + "type": "string", + "maxLength": 160, + "minLength": 0, + "nullable": false + }, + "summary": { + "type": "string", + "maxLength": 512, + "minLength": 0, + "nullable": false + }, + "body": { + "type": "string", + "maxLength": 8000, + "minLength": 0, + "nullable": true + }, + "category": { + "type": "string", + "maxLength": 32, + "minLength": 0, + "nullable": false + }, + "importance": { + "type": "string", + "maxLength": 32, + "minLength": 0, + "nullable": false + }, + "audience": { + "type": "string", + "maxLength": 32, + "minLength": 0, + "nullable": false + }, + "deploymentLabel": { + "type": "string", + "maxLength": 128, + "minLength": 0, + "nullable": true + }, + "buildVersion": { + "type": "string", + "maxLength": 128, + "minLength": 0, + "nullable": true + }, + "commitRange": { + "type": "string", + "maxLength": 256, + "minLength": 0, + "nullable": true + } + } + }, "SocializeApiModulesOrganizationsHandlersOrganizationMemberDto": { "type": "object", "additionalProperties": false,