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

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,118 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Socialize.Api.Migrations
{
/// <inheritdoc />
internal partial class SimplifyReleaseUpdates : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_ReleaseUpdates_Audience",
table: "ReleaseUpdates");
migrationBuilder.DropColumn(
name: "Audience",
table: "ReleaseUpdates");
migrationBuilder.DropColumn(
name: "Body",
table: "ReleaseUpdates");
migrationBuilder.DropColumn(
name: "BuildVersion",
table: "ReleaseUpdates");
migrationBuilder.DropColumn(
name: "Category",
table: "ReleaseUpdates");
migrationBuilder.DropColumn(
name: "CommitRange",
table: "ReleaseUpdates");
migrationBuilder.DropColumn(
name: "DeploymentLabel",
table: "ReleaseUpdates");
migrationBuilder.DropColumn(
name: "Importance",
table: "ReleaseUpdates");
migrationBuilder.DropColumn(
name: "ManualEmailAudience",
table: "ReleaseUpdates");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Audience",
table: "ReleaseUpdates",
type: "character varying(32)",
maxLength: 32,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "Body",
table: "ReleaseUpdates",
type: "character varying(8000)",
maxLength: 8000,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "BuildVersion",
table: "ReleaseUpdates",
type: "character varying(128)",
maxLength: 128,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Category",
table: "ReleaseUpdates",
type: "character varying(32)",
maxLength: 32,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "CommitRange",
table: "ReleaseUpdates",
type: "character varying(256)",
maxLength: 256,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "DeploymentLabel",
table: "ReleaseUpdates",
type: "character varying(128)",
maxLength: 128,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Importance",
table: "ReleaseUpdates",
type: "character varying(32)",
maxLength: 32,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "ManualEmailAudience",
table: "ReleaseUpdates",
type: "character varying(64)",
maxLength: 64,
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_ReleaseUpdates_Audience",
table: "ReleaseUpdates",
column: "Audience");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,49 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Socialize.Api.Migrations
{
/// <inheritdoc />
internal partial class AddFrenchReleaseUpdateFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "SummaryFr",
table: "ReleaseUpdates",
type: "character varying(512)",
maxLength: 512,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "TitleFr",
table: "ReleaseUpdates",
type: "character varying(160)",
maxLength: 160,
nullable: false,
defaultValue: "");
migrationBuilder.Sql("""
UPDATE "ReleaseUpdates"
SET "TitleFr" = "Title",
"SummaryFr" = "Summary"
WHERE "TitleFr" = '' AND "SummaryFr" = '';
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "SummaryFr",
table: "ReleaseUpdates");
migrationBuilder.DropColumn(
name: "TitleFr",
table: "ReleaseUpdates");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,53 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Socialize.Api.Migrations
{
/// <inheritdoc />
internal partial class RemoveManualReleaseUpdateEmail : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
ArgumentNullException.ThrowIfNull(migrationBuilder);
migrationBuilder.DropColumn(
name: "ManualEmailRecipientCount",
table: "ReleaseUpdates");
migrationBuilder.DropColumn(
name: "ManualEmailSentAt",
table: "ReleaseUpdates");
migrationBuilder.DropColumn(
name: "ManualEmailSentByUserId",
table: "ReleaseUpdates");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
ArgumentNullException.ThrowIfNull(migrationBuilder);
migrationBuilder.AddColumn<int>(
name: "ManualEmailRecipientCount",
table: "ReleaseUpdates",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<DateTimeOffset>(
name: "ManualEmailSentAt",
table: "ReleaseUpdates",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "ManualEmailSentByUserId",
table: "ReleaseUpdates",
type: "uuid",
nullable: true);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,62 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Socialize.Api.Migrations
{
/// <inheritdoc />
internal partial class ExpandReleaseUpdateDescriptions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
ArgumentNullException.ThrowIfNull(migrationBuilder);
migrationBuilder.AlterColumn<string>(
name: "SummaryFr",
table: "ReleaseUpdates",
type: "character varying(4000)",
maxLength: 4000,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(512)",
oldMaxLength: 512);
migrationBuilder.AlterColumn<string>(
name: "Summary",
table: "ReleaseUpdates",
type: "character varying(4000)",
maxLength: 4000,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(512)",
oldMaxLength: 512);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
ArgumentNullException.ThrowIfNull(migrationBuilder);
migrationBuilder.AlterColumn<string>(
name: "SummaryFr",
table: "ReleaseUpdates",
type: "character varying(512)",
maxLength: 512,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(4000)",
oldMaxLength: 4000);
migrationBuilder.AlterColumn<string>(
name: "Summary",
table: "ReleaseUpdates",
type: "character varying(512)",
maxLength: 512,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(4000)",
oldMaxLength: 4000);
}
}
}

View File

@@ -1980,28 +1980,6 @@ namespace Socialize.Api.Migrations
b.Property<DateTimeOffset?>("ArchivedAt") b.Property<DateTimeOffset?>("ArchivedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
b.Property<string>("Audience")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("Body")
.HasMaxLength(8000)
.HasColumnType("character varying(8000)");
b.Property<string>("BuildVersion")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("CommitRange")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<DateTimeOffset>("CreatedAt") b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
@@ -2010,28 +1988,6 @@ namespace Socialize.Api.Migrations
b.Property<Guid>("CreatedByUserId") b.Property<Guid>("CreatedByUserId")
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<string>("DeploymentLabel")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Importance")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("ManualEmailAudience")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int?>("ManualEmailRecipientCount")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("ManualEmailSentAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("ManualEmailSentByUserId")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("PublishedAt") b.Property<DateTimeOffset?>("PublishedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
@@ -2042,21 +1998,29 @@ namespace Socialize.Api.Migrations
b.Property<string>("Summary") b.Property<string>("Summary")
.IsRequired() .IsRequired()
.HasMaxLength(512) .HasMaxLength(4000)
.HasColumnType("character varying(512)"); .HasColumnType("character varying(4000)");
b.Property<string>("SummaryFr")
.IsRequired()
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<string>("Title") b.Property<string>("Title")
.IsRequired() .IsRequired()
.HasMaxLength(160) .HasMaxLength(160)
.HasColumnType("character varying(160)"); .HasColumnType("character varying(160)");
b.Property<string>("TitleFr")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)");
b.Property<DateTimeOffset>("UpdatedAt") b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("Audience");
b.HasIndex("CreatedByUserId"); b.HasIndex("CreatedByUserId");
b.HasIndex("PublishedAt"); b.HasIndex("PublishedAt");

View File

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

View File

@@ -11,19 +11,12 @@ internal static class ReleaseCommunicationsModelConfiguration
releaseUpdate.ToTable("ReleaseUpdates"); releaseUpdate.ToTable("ReleaseUpdates");
releaseUpdate.HasKey(x => x.Id); releaseUpdate.HasKey(x => x.Id);
releaseUpdate.Property(x => x.Title).HasMaxLength(160).IsRequired(); releaseUpdate.Property(x => x.Title).HasMaxLength(160).IsRequired();
releaseUpdate.Property(x => x.Summary).HasMaxLength(512).IsRequired(); releaseUpdate.Property(x => x.Summary).HasMaxLength(4000).IsRequired();
releaseUpdate.Property(x => x.Body).HasMaxLength(8000); releaseUpdate.Property(x => x.TitleFr).HasMaxLength(160).IsRequired();
releaseUpdate.Property(x => x.Category).HasConversion<string>().HasMaxLength(32).IsRequired(); releaseUpdate.Property(x => x.SummaryFr).HasMaxLength(4000).IsRequired();
releaseUpdate.Property(x => x.Importance).HasConversion<string>().HasMaxLength(32).IsRequired();
releaseUpdate.Property(x => x.Audience).HasConversion<string>().HasMaxLength(32).IsRequired();
releaseUpdate.Property(x => x.Status).HasConversion<string>().HasMaxLength(32).IsRequired(); releaseUpdate.Property(x => x.Status).HasConversion<string>().HasMaxLength(32).IsRequired();
releaseUpdate.Property(x => x.DeploymentLabel).HasMaxLength(128);
releaseUpdate.Property(x => x.BuildVersion).HasMaxLength(128);
releaseUpdate.Property(x => x.CommitRange).HasMaxLength(256);
releaseUpdate.Property(x => x.ManualEmailAudience).HasMaxLength(64);
releaseUpdate.Property(x => x.CreatedAt).ValueGeneratedOnAdd().HasDefaultValueSql("CURRENT_TIMESTAMP"); releaseUpdate.Property(x => x.CreatedAt).ValueGeneratedOnAdd().HasDefaultValueSql("CURRENT_TIMESTAMP");
releaseUpdate.HasIndex(x => x.Status); releaseUpdate.HasIndex(x => x.Status);
releaseUpdate.HasIndex(x => x.Audience);
releaseUpdate.HasIndex(x => x.PublishedAt); releaseUpdate.HasIndex(x => x.PublishedAt);
releaseUpdate.HasIndex(x => x.CreatedByUserId); releaseUpdate.HasIndex(x => x.CreatedByUserId);
}); });

View File

@@ -5,22 +5,13 @@ internal class ReleaseUpdate
public Guid Id { get; set; } public Guid Id { get; set; }
public string Title { get; set; } = string.Empty; public string Title { get; set; } = string.Empty;
public string Summary { get; set; } = string.Empty; public string Summary { get; set; } = string.Empty;
public string? Body { get; set; } public string TitleFr { get; set; } = string.Empty;
public ReleaseUpdateCategory Category { get; set; } public string SummaryFr { get; set; } = string.Empty;
public ReleaseUpdateImportance Importance { get; set; }
public ReleaseUpdateAudience Audience { get; set; }
public ReleaseUpdateStatus Status { 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 Guid CreatedByUserId { get; set; }
public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; } public DateTimeOffset UpdatedAt { get; set; }
public DateTimeOffset? PublishedAt { get; set; } public DateTimeOffset? PublishedAt { get; set; }
public DateTimeOffset? ArchivedAt { get; set; } public DateTimeOffset? ArchivedAt { get; set; }
public Guid? ManualEmailSentByUserId { get; set; }
public DateTimeOffset? ManualEmailSentAt { get; set; }
public string? ManualEmailAudience { get; set; }
public int? ManualEmailRecipientCount { get; set; }
public ICollection<ReleaseUpdateReadReceipt> ReadReceipts { get; } = new List<ReleaseUpdateReadReceipt>(); public ICollection<ReleaseUpdateReadReceipt> ReadReceipts { get; } = new List<ReleaseUpdateReadReceipt>();
} }

View File

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

View File

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

View File

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

View File

@@ -4,35 +4,24 @@ using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Identity.Contracts; using Socialize.Api.Modules.Identity.Contracts;
using Socialize.Api.Modules.ReleaseCommunications.Contracts; using Socialize.Api.Modules.ReleaseCommunications.Contracts;
using Socialize.Api.Modules.ReleaseCommunications.Data; using Socialize.Api.Modules.ReleaseCommunications.Data;
using Socialize.Api.Modules.ReleaseCommunications.Services;
namespace Socialize.Api.Modules.ReleaseCommunications.Handlers; namespace Socialize.Api.Modules.ReleaseCommunications.Handlers;
internal record CreateDeveloperReleaseUpdateRequest( internal record CreateDeveloperReleaseUpdateRequest(
string Title, string TitleEn,
string Summary, string DescriptionEn,
string? Body, string TitleFr,
string Category, string DescriptionFr);
string Importance,
string Audience,
string? DeploymentLabel,
string? BuildVersion,
string? CommitRange);
internal class CreateDeveloperReleaseUpdateRequestValidator internal class CreateDeveloperReleaseUpdateRequestValidator
: Validator<CreateDeveloperReleaseUpdateRequest> : Validator<CreateDeveloperReleaseUpdateRequest>
{ {
public CreateDeveloperReleaseUpdateRequestValidator() public CreateDeveloperReleaseUpdateRequestValidator()
{ {
RuleFor(x => x.Title).NotEmpty().MaximumLength(160); RuleFor(x => x.TitleEn).NotEmpty().MaximumLength(160);
RuleFor(x => x.Summary).NotEmpty().MaximumLength(512); RuleFor(x => x.DescriptionEn).NotEmpty().MaximumLength(4000);
RuleFor(x => x.Body).MaximumLength(8000); RuleFor(x => x.TitleFr).NotEmpty().MaximumLength(160);
RuleFor(x => x.Category).NotEmpty().MaximumLength(32); RuleFor(x => x.DescriptionFr).NotEmpty().MaximumLength(4000);
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);
} }
} }
@@ -48,26 +37,15 @@ internal class CreateDeveloperReleaseUpdateHandler(AppDbContext dbContext)
public override async Task HandleAsync(CreateDeveloperReleaseUpdateRequest request, CancellationToken ct) 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; DateTimeOffset now = DateTimeOffset.UtcNow;
ReleaseUpdate update = new() ReleaseUpdate update = new()
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
Title = request.Title.Trim(), Title = request.TitleEn.Trim(),
Summary = request.Summary.Trim(), Summary = request.DescriptionEn.Trim(),
Body = NormalizeOptional(request.Body), TitleFr = request.TitleFr.Trim(),
Category = category, SummaryFr = request.DescriptionFr.Trim(),
Importance = importance,
Audience = audience,
Status = ReleaseUpdateStatus.Draft, Status = ReleaseUpdateStatus.Draft,
DeploymentLabel = NormalizeOptional(request.DeploymentLabel),
BuildVersion = NormalizeOptional(request.BuildVersion),
CommitRange = NormalizeOptional(request.CommitRange),
CreatedByUserId = User.GetUserId(), CreatedByUserId = User.GetUserId(),
CreatedAt = now, CreatedAt = now,
UpdatedAt = now, UpdatedAt = now,
@@ -78,38 +56,4 @@ internal class CreateDeveloperReleaseUpdateHandler(AppDbContext dbContext)
await SendAsync(update.ToDto(false), StatusCodes.Status201Created, ct); await SendAsync(update.ToDto(false), StatusCodes.Status201Created, ct);
} }
private bool TryParseRequest(
CreateDeveloperReleaseUpdateRequest request,
out ReleaseUpdateCategory category,
out ReleaseUpdateImportance importance,
out ReleaseUpdateAudience audience)
{
bool isValid = true;
if (!ReleaseUpdateRules.TryParseCategory(request.Category, out category))
{
AddError(x => x.Category, "The selected release update category is not valid.");
isValid = false;
}
if (!ReleaseUpdateRules.TryParseImportance(request.Importance, out importance))
{
AddError(x => x.Importance, "The selected release update importance is not valid.");
isValid = false;
}
if (!ReleaseUpdateRules.TryParseAudience(request.Audience, out audience))
{
AddError(x => x.Audience, "The selected release update audience is not valid.");
isValid = false;
}
return isValid;
}
private static string? NormalizeOptional(string? value)
{
string? normalized = value?.Trim();
return string.IsNullOrWhiteSpace(normalized) ? null : normalized;
}
} }

View File

@@ -20,11 +20,9 @@ internal class GetUnreadReleaseUpdatesHandler(AppDbContext dbContext)
public override async Task HandleAsync(CancellationToken ct) public override async Task HandleAsync(CancellationToken ct)
{ {
Guid userId = User.GetUserId(); Guid userId = User.GetUserId();
ReleaseUpdateAudienceContext audienceContext =
await ReleaseUpdateVisibility.GetAudienceContextAsync(dbContext, User, userId, ct);
List<ReleaseUpdate> unreadUpdates = await dbContext.ReleaseUpdates List<ReleaseUpdate> unreadUpdates = await dbContext.ReleaseUpdates
.VisibleTo(audienceContext) .VisibleToUsers()
.Where(update => !dbContext.ReleaseUpdateReadReceipts.Any(receipt => .Where(update => !dbContext.ReleaseUpdateReadReceipts.Any(receipt =>
receipt.ReleaseUpdateId == update.Id && receipt.ReleaseUpdateId == update.Id &&
receipt.UserId == userId)) receipt.UserId == userId))
@@ -35,7 +33,7 @@ internal class GetUnreadReleaseUpdatesHandler(AppDbContext dbContext)
await SendOkAsync( await SendOkAsync(
new ReleaseUpdateUnreadSummaryDto( new ReleaseUpdateUnreadSummaryDto(
unreadUpdates.Count, unreadUpdates.Count,
unreadUpdates.Count(update => update.Importance == ReleaseUpdateImportance.Important), 0,
unreadUpdates.Select(update => update.ToDto(false)).ToArray()), unreadUpdates.Select(update => update.ToDto(false)).ToArray()),
ct); ct);
} }

View File

@@ -1,156 +0,0 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using Socialize.Api.Data;
using Socialize.Api.Modules.Identity.Contracts;
using Socialize.Api.Modules.ReleaseCommunications.Contracts;
using Socialize.Api.Modules.ReleaseCommunications.Data;
using Socialize.Api.Modules.ReleaseCommunications.Services;
namespace Socialize.Api.Modules.ReleaseCommunications.Handlers;
internal record ImportDeveloperReleaseCommitDto(
string Sha,
string? ShortSha,
string Subject,
string? AuthorName,
string? AuthorEmail,
DateTimeOffset? AuthoredAt,
DateTimeOffset? CommittedAt,
string? SourceBranch,
string? DeploymentLabel,
string? ExternalUrl);
internal record ImportDeveloperReleaseCommitsRequest(
string? SinceSha,
string? UntilSha,
string? SourceBranch,
string? DeploymentLabel,
DateTimeOffset? Since,
DateTimeOffset? Until,
int? Limit,
IReadOnlyCollection<ImportDeveloperReleaseCommitDto>? Commits);
internal class ImportDeveloperReleaseCommitsHandler(
AppDbContext dbContext,
ReleaseCommitRepositoryImportService repositoryImportService)
: Endpoint<ImportDeveloperReleaseCommitsRequest, ReleaseCommitImportResultDto>
{
public override void Configure()
{
Post("/api/developer/release-commits/import");
Roles(KnownRoles.Developer);
Options(o => o.WithTags("Release Communications"));
}
public override async Task HandleAsync(ImportDeveloperReleaseCommitsRequest request, CancellationToken ct)
{
IReadOnlyCollection<ReleaseCommit> requestedCommits;
if (request.Commits is { Count: > 0 })
{
requestedCommits = request.Commits.Select(ToReleaseCommit).ToArray();
}
else
{
try
{
ReleaseCommitRepositoryImportResult importResult = await repositoryImportService.FetchCommitsAsync(request, ct);
if (!importResult.IsSuccess)
{
AddError(importResult.ErrorMessage ?? "Repository commit import failed.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
requestedCommits = importResult.Commits;
}
catch (HttpRequestException ex)
{
AddError(ex.Message);
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
catch (JsonException ex)
{
AddError(ex.Message);
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
}
int imported = 0;
int updated = 0;
int skipped = 0;
List<ReleaseCommit> savedCommits = [];
foreach (ReleaseCommit requestedCommit in requestedCommits)
{
if (string.IsNullOrWhiteSpace(requestedCommit.Sha) || string.IsNullOrWhiteSpace(requestedCommit.Subject))
{
skipped++;
continue;
}
ReleaseCommit? existingCommit = await dbContext.ReleaseCommits.SingleOrDefaultAsync(
commit => commit.Sha == requestedCommit.Sha,
ct);
if (existingCommit is null)
{
dbContext.ReleaseCommits.Add(requestedCommit);
savedCommits.Add(requestedCommit);
imported++;
continue;
}
existingCommit.ShortSha = requestedCommit.ShortSha;
existingCommit.Subject = requestedCommit.Subject;
existingCommit.AuthorName = requestedCommit.AuthorName;
existingCommit.AuthorEmail = requestedCommit.AuthorEmail;
existingCommit.AuthoredAt = requestedCommit.AuthoredAt;
existingCommit.CommittedAt = requestedCommit.CommittedAt;
existingCommit.SourceBranch = requestedCommit.SourceBranch ?? existingCommit.SourceBranch;
existingCommit.DeploymentLabel = requestedCommit.DeploymentLabel ?? existingCommit.DeploymentLabel;
existingCommit.ExternalUrl = requestedCommit.ExternalUrl ?? existingCommit.ExternalUrl;
existingCommit.UpdatedAt = DateTimeOffset.UtcNow;
savedCommits.Add(existingCommit);
updated++;
}
await dbContext.SaveChangesAsync(ct);
await SendOkAsync(
new ReleaseCommitImportResultDto(imported, updated, skipped, savedCommits.Select(commit => commit.ToDto()).ToArray()),
ct);
}
private static ReleaseCommit ToReleaseCommit(ImportDeveloperReleaseCommitDto dto)
{
DateTimeOffset now = DateTimeOffset.UtcNow;
return new ReleaseCommit
{
Sha = dto.Sha.Trim(),
ShortSha = NormalizeOptional(dto.ShortSha) ?? dto.Sha.Trim()[..Math.Min(dto.Sha.Trim().Length, 12)],
Subject = dto.Subject.Trim(),
AuthorName = NormalizeOptional(dto.AuthorName),
AuthorEmail = NormalizeOptional(dto.AuthorEmail),
AuthoredAt = ToUtc(dto.AuthoredAt),
CommittedAt = ToUtc(dto.CommittedAt),
SourceBranch = NormalizeOptional(dto.SourceBranch),
DeploymentLabel = NormalizeOptional(dto.DeploymentLabel),
ExternalUrl = NormalizeOptional(dto.ExternalUrl),
CommunicationStatus = ReleaseCommitCommunicationStatus.Unreviewed,
ImportedAt = now,
UpdatedAt = now,
};
}
private static string? NormalizeOptional(string? value)
{
string? normalized = value?.Trim();
return string.IsNullOrWhiteSpace(normalized) ? null : normalized;
}
private static DateTimeOffset? ToUtc(DateTimeOffset? value)
{
return value?.ToUniversalTime();
}
}

View File

@@ -20,11 +20,9 @@ internal class ListReleaseUpdatesHandler(AppDbContext dbContext)
public override async Task HandleAsync(CancellationToken ct) public override async Task HandleAsync(CancellationToken ct)
{ {
Guid userId = User.GetUserId(); Guid userId = User.GetUserId();
ReleaseUpdateAudienceContext audienceContext =
await ReleaseUpdateVisibility.GetAudienceContextAsync(dbContext, User, userId, ct);
List<ReleaseUpdate> updates = await dbContext.ReleaseUpdates List<ReleaseUpdate> updates = await dbContext.ReleaseUpdates
.VisibleTo(audienceContext) .VisibleToUsers()
.OrderByDescending(update => update.PublishedAt) .OrderByDescending(update => update.PublishedAt)
.ThenByDescending(update => update.CreatedAt) .ThenByDescending(update => update.CreatedAt)
.ToListAsync(ct); .ToListAsync(ct);

View File

@@ -19,11 +19,9 @@ internal class MarkAllReleaseUpdatesReadHandler(AppDbContext dbContext)
public override async Task HandleAsync(CancellationToken ct) public override async Task HandleAsync(CancellationToken ct)
{ {
Guid userId = User.GetUserId(); Guid userId = User.GetUserId();
ReleaseUpdateAudienceContext audienceContext =
await ReleaseUpdateVisibility.GetAudienceContextAsync(dbContext, User, userId, ct);
List<Guid> visibleUpdateIds = await dbContext.ReleaseUpdates List<Guid> visibleUpdateIds = await dbContext.ReleaseUpdates
.VisibleTo(audienceContext) .VisibleToUsers()
.Select(update => update.Id) .Select(update => update.Id)
.ToListAsync(ct); .ToListAsync(ct);

View File

@@ -20,11 +20,9 @@ internal class MarkReleaseUpdateReadHandler(AppDbContext dbContext)
{ {
Guid id = Route<Guid>("id"); Guid id = Route<Guid>("id");
Guid userId = User.GetUserId(); Guid userId = User.GetUserId();
ReleaseUpdateAudienceContext audienceContext =
await ReleaseUpdateVisibility.GetAudienceContextAsync(dbContext, User, userId, ct);
bool canReadUpdate = await dbContext.ReleaseUpdates bool canReadUpdate = await dbContext.ReleaseUpdates
.VisibleTo(audienceContext) .VisibleToUsers()
.AnyAsync(update => update.Id == id, ct); .AnyAsync(update => update.Id == id, ct);
if (!canReadUpdate) if (!canReadUpdate)

View File

@@ -0,0 +1,95 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using Socialize.Api.Data;
using Socialize.Api.Modules.Identity.Contracts;
using Socialize.Api.Modules.ReleaseCommunications.Contracts;
using Socialize.Api.Modules.ReleaseCommunications.Data;
using Socialize.Api.Modules.ReleaseCommunications.Services;
namespace Socialize.Api.Modules.ReleaseCommunications.Handlers;
internal class RefreshDeveloperReleaseCommitsHandler(
AppDbContext dbContext,
ReleaseCommitRepositoryRefreshService repositoryRefreshService)
: EndpointWithoutRequest<ReleaseCommitRefreshResultDto>
{
public override void Configure()
{
Post("/api/developer/release-commits/refresh");
Roles(KnownRoles.Developer);
Options(o => o.WithTags("Release Communications"));
}
public override async Task HandleAsync(CancellationToken ct)
{
IReadOnlyCollection<ReleaseCommit> requestedCommits;
try
{
ReleaseCommitRepositoryRefreshResult refreshResult = await repositoryRefreshService.FetchCommitsAsync(ct);
if (!refreshResult.IsSuccess)
{
AddError(refreshResult.ErrorMessage ?? "Repository commit refresh failed.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
requestedCommits = refreshResult.Commits;
}
catch (HttpRequestException ex)
{
AddError(ex.Message);
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
catch (JsonException ex)
{
AddError(ex.Message);
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
int created = 0;
int updated = 0;
int skipped = 0;
List<ReleaseCommit> savedCommits = [];
foreach (ReleaseCommit requestedCommit in requestedCommits)
{
if (string.IsNullOrWhiteSpace(requestedCommit.Sha) || string.IsNullOrWhiteSpace(requestedCommit.Subject))
{
skipped++;
continue;
}
ReleaseCommit? existingCommit = await dbContext.ReleaseCommits.SingleOrDefaultAsync(
commit => commit.Sha == requestedCommit.Sha,
ct);
if (existingCommit is null)
{
dbContext.ReleaseCommits.Add(requestedCommit);
savedCommits.Add(requestedCommit);
created++;
continue;
}
existingCommit.ShortSha = requestedCommit.ShortSha;
existingCommit.Subject = requestedCommit.Subject;
existingCommit.AuthorName = requestedCommit.AuthorName;
existingCommit.AuthorEmail = requestedCommit.AuthorEmail;
existingCommit.AuthoredAt = requestedCommit.AuthoredAt;
existingCommit.CommittedAt = requestedCommit.CommittedAt;
existingCommit.SourceBranch = requestedCommit.SourceBranch ?? existingCommit.SourceBranch;
existingCommit.DeploymentLabel = requestedCommit.DeploymentLabel ?? existingCommit.DeploymentLabel;
existingCommit.ExternalUrl = requestedCommit.ExternalUrl ?? existingCommit.ExternalUrl;
existingCommit.UpdatedAt = DateTimeOffset.UtcNow;
savedCommits.Add(existingCommit);
updated++;
}
await dbContext.SaveChangesAsync(ct);
await SendOkAsync(
new ReleaseCommitRefreshResultDto(created, updated, skipped, savedCommits.Select(commit => commit.ToDto()).ToArray()),
ct);
}
}

View File

@@ -1,55 +0,0 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Identity.Contracts;
using Socialize.Api.Modules.ReleaseCommunications.Contracts;
using Socialize.Api.Modules.ReleaseCommunications.Data;
using Socialize.Api.Modules.ReleaseCommunications.Services;
namespace Socialize.Api.Modules.ReleaseCommunications.Handlers;
internal record SendDeveloperReleaseUpdateEmailRequest(
bool TestMode,
bool ConfirmResend);
internal class SendDeveloperReleaseUpdateEmailHandler(
AppDbContext dbContext,
ReleaseUpdateEmailService emailService)
: Endpoint<SendDeveloperReleaseUpdateEmailRequest, ReleaseUpdateEmailSendResultDto>
{
public override void Configure()
{
Post("/api/developer/release-updates/{id}/send-email");
Roles(KnownRoles.Developer);
Options(o => o.WithTags("Release Communications"));
}
public override async Task HandleAsync(SendDeveloperReleaseUpdateEmailRequest request, CancellationToken ct)
{
Guid id = Route<Guid>("id");
ReleaseUpdate? update = await dbContext.ReleaseUpdates.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
if (update is null)
{
await SendNotFoundAsync(ct);
return;
}
try
{
ReleaseUpdateEmailSendResultDto result = await emailService.SendManualUpdateEmailAsync(
update,
User.GetUserId(),
request.TestMode,
request.ConfirmResend,
ct);
await dbContext.SaveChangesAsync(ct);
await SendOkAsync(result, ct);
}
catch (InvalidOperationException ex)
{
AddError(ex.Message);
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
}
}
}

View File

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

View File

@@ -4,35 +4,24 @@ using Socialize.Api.Data;
using Socialize.Api.Modules.Identity.Contracts; using Socialize.Api.Modules.Identity.Contracts;
using Socialize.Api.Modules.ReleaseCommunications.Contracts; using Socialize.Api.Modules.ReleaseCommunications.Contracts;
using Socialize.Api.Modules.ReleaseCommunications.Data; using Socialize.Api.Modules.ReleaseCommunications.Data;
using Socialize.Api.Modules.ReleaseCommunications.Services;
namespace Socialize.Api.Modules.ReleaseCommunications.Handlers; namespace Socialize.Api.Modules.ReleaseCommunications.Handlers;
internal record UpdateDeveloperReleaseUpdateRequest( internal record UpdateDeveloperReleaseUpdateRequest(
string Title, string TitleEn,
string Summary, string DescriptionEn,
string? Body, string TitleFr,
string Category, string DescriptionFr);
string Importance,
string Audience,
string? DeploymentLabel,
string? BuildVersion,
string? CommitRange);
internal class UpdateDeveloperReleaseUpdateRequestValidator internal class UpdateDeveloperReleaseUpdateRequestValidator
: Validator<UpdateDeveloperReleaseUpdateRequest> : Validator<UpdateDeveloperReleaseUpdateRequest>
{ {
public UpdateDeveloperReleaseUpdateRequestValidator() public UpdateDeveloperReleaseUpdateRequestValidator()
{ {
RuleFor(x => x.Title).NotEmpty().MaximumLength(160); RuleFor(x => x.TitleEn).NotEmpty().MaximumLength(160);
RuleFor(x => x.Summary).NotEmpty().MaximumLength(512); RuleFor(x => x.DescriptionEn).NotEmpty().MaximumLength(4000);
RuleFor(x => x.Body).MaximumLength(8000); RuleFor(x => x.TitleFr).NotEmpty().MaximumLength(160);
RuleFor(x => x.Category).NotEmpty().MaximumLength(32); RuleFor(x => x.DescriptionFr).NotEmpty().MaximumLength(4000);
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);
} }
} }
@@ -63,58 +52,13 @@ internal class UpdateDeveloperReleaseUpdateHandler(AppDbContext dbContext)
return; return;
} }
if (!TryParseRequest(request, out ReleaseUpdateCategory category, out ReleaseUpdateImportance importance, out ReleaseUpdateAudience audience)) update.Title = request.TitleEn.Trim();
{ update.Summary = request.DescriptionEn.Trim();
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct); update.TitleFr = request.TitleFr.Trim();
return; update.SummaryFr = request.DescriptionFr.Trim();
}
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; update.UpdatedAt = DateTimeOffset.UtcNow;
await dbContext.SaveChangesAsync(ct); await dbContext.SaveChangesAsync(ct);
await SendOkAsync(update.ToDto(false), ct); await SendOkAsync(update.ToDto(false), ct);
} }
private bool TryParseRequest(
UpdateDeveloperReleaseUpdateRequest request,
out ReleaseUpdateCategory category,
out ReleaseUpdateImportance importance,
out ReleaseUpdateAudience audience)
{
bool isValid = true;
if (!ReleaseUpdateRules.TryParseCategory(request.Category, out category))
{
AddError(x => x.Category, "The selected release update category is not valid.");
isValid = false;
}
if (!ReleaseUpdateRules.TryParseImportance(request.Importance, out importance))
{
AddError(x => x.Importance, "The selected release update importance is not valid.");
isValid = false;
}
if (!ReleaseUpdateRules.TryParseAudience(request.Audience, out audience))
{
AddError(x => x.Audience, "The selected release update audience is not valid.");
isValid = false;
}
return isValid;
}
private static string? NormalizeOptional(string? value)
{
string? normalized = value?.Trim();
return string.IsNullOrWhiteSpace(normalized) ? null : normalized;
}
} }

View File

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

View File

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

View File

@@ -1,15 +1,11 @@
using System.Net; using System.Net;
using System.Security.Claims;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Socialize.Api.Data; using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Configuration; using Socialize.Api.Infrastructure.Configuration;
using Socialize.Api.Infrastructure.Emailer.Contracts; using Socialize.Api.Infrastructure.Emailer.Contracts;
using Socialize.Api.Modules.Identity.Contracts;
using Socialize.Api.Modules.Identity.Data; using Socialize.Api.Modules.Identity.Data;
using Socialize.Api.Modules.Organizations.Services;
using Socialize.Api.Modules.ReleaseCommunications.Contracts;
using Socialize.Api.Modules.ReleaseCommunications.Data; using Socialize.Api.Modules.ReleaseCommunications.Data;
namespace Socialize.Api.Modules.ReleaseCommunications.Services; namespace Socialize.Api.Modules.ReleaseCommunications.Services;
@@ -20,48 +16,6 @@ internal class ReleaseUpdateEmailService(
IEmailSender emailSender, IEmailSender emailSender,
IOptionsSnapshot<WebsiteOptions> websiteOptions) IOptionsSnapshot<WebsiteOptions> websiteOptions)
{ {
public async Task<ReleaseUpdateEmailSendResultDto> SendManualUpdateEmailAsync(
ReleaseUpdate update,
Guid senderUserId,
bool testMode,
bool confirmResend,
CancellationToken ct)
{
if (update.Status != ReleaseUpdateStatus.Published)
{
throw new InvalidOperationException("Only published release updates can be emailed.");
}
if (!testMode && update.ManualEmailSentAt.HasValue && !confirmResend)
{
throw new InvalidOperationException("This release update was already emailed. Confirm resend to send it again.");
}
IReadOnlyCollection<User> recipients = testMode
? await GetTestRecipientsAsync(senderUserId, ct)
: await GetAudienceRecipientsAsync(update.Audience, ct);
DateTimeOffset now = DateTimeOffset.UtcNow;
foreach (User recipient in recipients.Where(recipient => !string.IsNullOrWhiteSpace(recipient.Email)))
{
await emailSender.SendEmailAsync(
recipient.Email!,
$"What's new in Socialize: {update.Title}",
BuildSingleUpdateEmail(update));
}
if (!testMode)
{
update.ManualEmailSentByUserId = senderUserId;
update.ManualEmailSentAt = now;
update.ManualEmailAudience = update.Audience.ToString();
update.ManualEmailRecipientCount = recipients.Count;
update.UpdatedAt = now;
}
return new ReleaseUpdateEmailSendResultDto(recipients.Count, now, testMode);
}
public async Task<int> SendDueDigestEmailsAsync( public async Task<int> SendDueDigestEmailsAsync(
TimeSpan inactiveThreshold, TimeSpan inactiveThreshold,
TimeSpan sendInterval, TimeSpan sendInterval,
@@ -71,7 +25,7 @@ internal class ReleaseUpdateEmailService(
DateTimeOffset inactiveBefore = now.Subtract(inactiveThreshold); DateTimeOffset inactiveBefore = now.Subtract(inactiveThreshold);
DateTimeOffset lastSentBefore = now.Subtract(sendInterval); DateTimeOffset lastSentBefore = now.Subtract(sendInterval);
List<User> ownerUsers = await GetAudienceRecipientsAsync(ReleaseUpdateAudience.OrganizationOwners, ct); List<User> ownerUsers = await GetReleaseNoteRecipientsAsync(ct);
int sentCount = 0; int sentCount = 0;
foreach (User user in ownerUsers) foreach (User user in ownerUsers)
{ {
@@ -91,14 +45,8 @@ internal class ReleaseUpdateEmailService(
continue; continue;
} }
ReleaseUpdateAudienceContext audienceContext = await ReleaseUpdateVisibility.GetAudienceContextAsync(
dbContext,
new ClaimsPrincipal(new ClaimsIdentity()),
user.Id,
ct);
List<ReleaseUpdate> unreadUpdates = await dbContext.ReleaseUpdates List<ReleaseUpdate> unreadUpdates = await dbContext.ReleaseUpdates
.VisibleTo(audienceContext) .VisibleToUsers()
.Where(update => !dbContext.ReleaseUpdateReadReceipts.Any(receipt => .Where(update => !dbContext.ReleaseUpdateReadReceipts.Any(receipt =>
receipt.ReleaseUpdateId == update.Id && receipt.ReleaseUpdateId == update.Id &&
receipt.UserId == user.Id)) receipt.UserId == user.Id))
@@ -130,48 +78,12 @@ internal class ReleaseUpdateEmailService(
return sentCount; return sentCount;
} }
private async Task<IReadOnlyCollection<User>> GetTestRecipientsAsync(Guid senderUserId, CancellationToken ct) private async Task<List<User>> GetReleaseNoteRecipientsAsync(CancellationToken ct)
{ {
User? sender = await userManager.Users.SingleOrDefaultAsync(user => user.Id == senderUserId, ct); return await userManager.Users
return sender is null ? [] : [sender]; .Where(user => user.EmailConfirmed && user.Email != null)
} .OrderBy(user => user.Email)
.ToListAsync(ct);
private async Task<List<User>> GetAudienceRecipientsAsync(ReleaseUpdateAudience audience, CancellationToken ct)
{
IQueryable<User> query = userManager.Users.Where(user => user.EmailConfirmed && user.Email != null);
if (audience == ReleaseUpdateAudience.Developers)
{
IList<User> developers = await userManager.GetUsersInRoleAsync(KnownRoles.Developer);
return developers.Where(user => user.EmailConfirmed && !string.IsNullOrWhiteSpace(user.Email)).ToList();
}
if (audience == ReleaseUpdateAudience.OrganizationOwners)
{
Guid[] ownerUserIds = await dbContext.Organizations
.Select(organization => organization.OwnerUserId)
.Concat(dbContext.OrganizationMemberships
.Where(membership => membership.Role == OrganizationRoles.Owner)
.Select(membership => membership.UserId))
.Distinct()
.ToArrayAsync(ct);
query = query.Where(user => ownerUserIds.Contains(user.Id));
}
return await query.OrderBy(user => user.Email).ToListAsync(ct);
}
private string BuildSingleUpdateEmail(ReleaseUpdate update)
{
string updateUrl = $"{websiteOptions.Value.FrontendBaseUrl.TrimEnd('/')}/app/updates?updateId={update.Id}";
return $"""
<h1>{HtmlEncode(update.Title)}</h1>
<p><strong>{HtmlEncode(update.Category.ToString())}</strong></p>
<p>{HtmlEncode(update.Summary)}</p>
{FormatBody(update.Body)}
<p><a href="{HtmlEncode(updateUrl)}">Open What's New</a></p>
""";
} }
private string BuildDigestEmail(IReadOnlyCollection<ReleaseUpdate> updates) private string BuildDigestEmail(IReadOnlyCollection<ReleaseUpdate> updates)
@@ -179,7 +91,12 @@ internal class ReleaseUpdateEmailService(
string updateUrl = $"{websiteOptions.Value.FrontendBaseUrl.TrimEnd('/')}/app/updates"; string updateUrl = $"{websiteOptions.Value.FrontendBaseUrl.TrimEnd('/')}/app/updates";
string listItems = string.Join( string listItems = string.Join(
Environment.NewLine, Environment.NewLine,
updates.Select(update => $"<li><strong>{HtmlEncode(update.Title)}</strong><br>{HtmlEncode(update.Summary)}</li>")); updates.Select(update => $"""
<li>
<strong>{HtmlEncode(update.Title)}</strong><br>{HtmlEncode(update.Summary)}<br>
<strong>{HtmlEncode(update.TitleFr)}</strong><br>{HtmlEncode(update.SummaryFr)}
</li>
"""));
return $""" return $"""
<h1>What's new in Socialize</h1> <h1>What's new in Socialize</h1>
@@ -188,13 +105,6 @@ internal class ReleaseUpdateEmailService(
"""; """;
} }
private static string FormatBody(string? body)
{
return string.IsNullOrWhiteSpace(body)
? string.Empty
: $"<p>{HtmlEncode(body).Replace(Environment.NewLine, "<br>", StringComparison.Ordinal)}</p>";
}
private static string HtmlEncode(string? value) private static string HtmlEncode(string? value)
{ {
return WebUtility.HtmlEncode(value ?? string.Empty); return WebUtility.HtmlEncode(value ?? string.Empty);

View File

@@ -1,28 +0,0 @@
using Socialize.Api.Modules.ReleaseCommunications.Data;
namespace Socialize.Api.Modules.ReleaseCommunications.Services;
internal static class ReleaseUpdateRules
{
public static bool TryParseCategory(string value, out ReleaseUpdateCategory category)
{
return TryParseEnum(value, out category);
}
public static bool TryParseImportance(string value, out ReleaseUpdateImportance importance)
{
return TryParseEnum(value, out importance);
}
public static bool TryParseAudience(string value, out ReleaseUpdateAudience audience)
{
return TryParseEnum(value, out audience);
}
private static bool TryParseEnum<TEnum>(string value, out TEnum result)
where TEnum : struct
{
string normalized = value.Replace(" ", string.Empty, StringComparison.Ordinal);
return Enum.TryParse(normalized, ignoreCase: true, out result);
}
}

View File

@@ -1,46 +1,11 @@
using System.Security.Claims;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Modules.Identity.Contracts;
using Socialize.Api.Modules.Organizations.Data;
using Socialize.Api.Modules.Organizations.Services;
using Socialize.Api.Modules.ReleaseCommunications.Data; using Socialize.Api.Modules.ReleaseCommunications.Data;
namespace Socialize.Api.Modules.ReleaseCommunications.Services; namespace Socialize.Api.Modules.ReleaseCommunications.Services;
internal static class ReleaseUpdateVisibility internal static class ReleaseUpdateVisibility
{ {
public static async Task<ReleaseUpdateAudienceContext> GetAudienceContextAsync( public static IQueryable<ReleaseUpdate> VisibleToUsers(this IQueryable<ReleaseUpdate> query)
AppDbContext dbContext,
ClaimsPrincipal user,
Guid userId,
CancellationToken ct)
{ {
bool isDeveloper = user.IsInRole(KnownRoles.Developer); return query.Where(update => update.Status == ReleaseUpdateStatus.Published);
bool isOrganizationOwner = await dbContext.Organizations.AnyAsync(
organization => organization.OwnerUserId == userId,
ct)
|| await dbContext.OrganizationMemberships.AnyAsync(
membership =>
membership.UserId == userId &&
membership.Role == OrganizationRoles.Owner,
ct);
return new ReleaseUpdateAudienceContext(isDeveloper, isOrganizationOwner);
}
public static IQueryable<ReleaseUpdate> VisibleTo(
this IQueryable<ReleaseUpdate> query,
ReleaseUpdateAudienceContext context)
{
return query.Where(update =>
update.Status == ReleaseUpdateStatus.Published &&
(update.Audience == ReleaseUpdateAudience.Everyone ||
(update.Audience == ReleaseUpdateAudience.OrganizationOwners && context.IsOrganizationOwner) ||
(update.Audience == ReleaseUpdateAudience.Developers && context.IsDeveloper)));
} }
} }
internal record ReleaseUpdateAudienceContext(
bool IsDeveloper,
bool IsOrganizationOwner);

View File

@@ -6,64 +6,16 @@ namespace Socialize.Tests.ReleaseCommunications;
public class ReleaseUpdateRulesTests 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] [Fact]
public void ToDto_formats_breaking_change_category_for_display() public void ToDto_maps_summary_to_description()
{ {
ReleaseUpdate update = new() ReleaseUpdate update = new()
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
Title = "API change", Title = "API change",
Summary = "A workflow API changed.", Summary = "A workflow API changed.",
Category = ReleaseUpdateCategory.BreakingChange, TitleFr = "Changement API",
Importance = ReleaseUpdateImportance.Important, SummaryFr = "Une API du flux de travail a change.",
Audience = ReleaseUpdateAudience.Developers,
Status = ReleaseUpdateStatus.Published, Status = ReleaseUpdateStatus.Published,
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow,
@@ -73,18 +25,22 @@ public class ReleaseUpdateRulesTests
ReleaseUpdateDto dto = update.ToDto(isRead: true); ReleaseUpdateDto dto = update.ToDto(isRead: true);
Assert.Equal("Breaking Change", dto.Category); Assert.Equal("A workflow API changed.", dto.Description);
Assert.Equal("API change", dto.TitleEn);
Assert.Equal("A workflow API changed.", dto.DescriptionEn);
Assert.Equal("Changement API", dto.TitleFr);
Assert.Equal("Une API du flux de travail a change.", dto.DescriptionFr);
Assert.True(dto.IsRead); Assert.True(dto.IsRead);
} }
[Fact] [Fact]
public void VisibleTo_returns_everyone_updates_for_any_authenticated_user() public void VisibleToUsers_returns_published_updates()
{ {
ReleaseUpdate update = NewPublishedUpdate(ReleaseUpdateAudience.Everyone); ReleaseUpdate update = NewPublishedUpdate();
List<ReleaseUpdate> visibleUpdates = new[] { update } List<ReleaseUpdate> visibleUpdates = new[] { update }
.AsQueryable() .AsQueryable()
.VisibleTo(new ReleaseUpdateAudienceContext(IsDeveloper: false, IsOrganizationOwner: false)) .VisibleToUsers()
.ToList(); .ToList();
Assert.Same(update, Assert.Single(visibleUpdates)); Assert.Same(update, Assert.Single(visibleUpdates));
@@ -93,37 +49,17 @@ public class ReleaseUpdateRulesTests
[Fact] [Fact]
public void VisibleTo_rejects_unpublished_updates() public void VisibleTo_rejects_unpublished_updates()
{ {
ReleaseUpdate update = NewPublishedUpdate(ReleaseUpdateAudience.Everyone); ReleaseUpdate update = NewPublishedUpdate();
update.Status = ReleaseUpdateStatus.Draft; update.Status = ReleaseUpdateStatus.Draft;
List<ReleaseUpdate> visibleUpdates = new[] { update } List<ReleaseUpdate> visibleUpdates = new[] { update }
.AsQueryable() .AsQueryable()
.VisibleTo(new ReleaseUpdateAudienceContext(IsDeveloper: true, IsOrganizationOwner: true)) .VisibleToUsers()
.ToList(); .ToList();
Assert.Empty(visibleUpdates); Assert.Empty(visibleUpdates);
} }
[Fact]
public void VisibleTo_requires_matching_restricted_audience()
{
ReleaseUpdate ownerUpdate = NewPublishedUpdate(ReleaseUpdateAudience.OrganizationOwners);
ReleaseUpdate developerUpdate = NewPublishedUpdate(ReleaseUpdateAudience.Developers);
List<ReleaseUpdate> ownerVisibleUpdates = new[] { ownerUpdate, developerUpdate }
.AsQueryable()
.VisibleTo(new ReleaseUpdateAudienceContext(IsDeveloper: false, IsOrganizationOwner: true))
.ToList();
List<ReleaseUpdate> developerVisibleUpdates = new[] { ownerUpdate, developerUpdate }
.AsQueryable()
.VisibleTo(new ReleaseUpdateAudienceContext(IsDeveloper: true, IsOrganizationOwner: false))
.ToList();
Assert.Same(ownerUpdate, Assert.Single(ownerVisibleUpdates));
Assert.Same(developerUpdate, Assert.Single(developerVisibleUpdates));
}
[Fact] [Fact]
public void CreateMissingReadReceipts_creates_receipts_only_for_unread_visible_updates() public void CreateMissingReadReceipts_creates_receipts_only_for_unread_visible_updates()
{ {
@@ -172,16 +108,15 @@ public class ReleaseUpdateRulesTests
Assert.False(ReleaseUpdateEmailRules.CanSendDigest(lastSentBefore.AddMinutes(1), lastSentBefore)); Assert.False(ReleaseUpdateEmailRules.CanSendDigest(lastSentBefore.AddMinutes(1), lastSentBefore));
} }
private static ReleaseUpdate NewPublishedUpdate(ReleaseUpdateAudience audience) private static ReleaseUpdate NewPublishedUpdate()
{ {
return new ReleaseUpdate return new ReleaseUpdate
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
Title = "Update", Title = "Update",
Summary = "Something changed.", Summary = "Something changed.",
Category = ReleaseUpdateCategory.Improvement, TitleFr = "Mise a jour",
Importance = ReleaseUpdateImportance.Normal, SummaryFr = "Quelque chose a change.",
Audience = audience,
Status = ReleaseUpdateStatus.Published, Status = ReleaseUpdateStatus.Published,
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow,

View File

@@ -112,7 +112,7 @@ The system should not rely only on the user's last login timestamp. Login is one
- Developer-only release communication back office: - Developer-only release communication back office:
- `/app/developer/updates` - `/app/developer/updates`
- `/app/developer/updates/:id` - `/app/developer/updates/:id`
- `/app/developer/release-commits` - `/app/developer/release-notes`
Feature-owned frontend code belongs under: Feature-owned frontend code belongs under:
@@ -235,9 +235,8 @@ GET /api/developer/release-updates/{id}
PUT /api/developer/release-updates/{id} PUT /api/developer/release-updates/{id}
POST /api/developer/release-updates/{id}/publish POST /api/developer/release-updates/{id}/publish
POST /api/developer/release-updates/{id}/archive POST /api/developer/release-updates/{id}/archive
POST /api/developer/release-updates/{id}/send-email
GET /api/developer/release-commits GET /api/developer/release-commits
POST /api/developer/release-commits/import POST /api/developer/release-commits/refresh
POST /api/developer/release-commits/{sha}/link POST /api/developer/release-commits/{sha}/link
POST /api/developer/release-commits/{sha}/unlink POST /api/developer/release-commits/{sha}/unlink
POST /api/developer/release-commits/{sha}/internal-only POST /api/developer/release-commits/{sha}/internal-only

View File

@@ -24,7 +24,7 @@ Add the developer back-office workflow for importing shipped commits and matchin
- mark a commit internal-only - mark a commit internal-only
- mark a commit ignored - mark a commit ignored
- Add developer-only frontend screens: - Add developer-only frontend screens:
- `/app/developer/release-commits` - `/app/developer/release-notes`
- linked commits on `/app/developer/updates/:id` - linked commits on `/app/developer/updates/:id`
- Add repository-backed import from configured HTTPS repository settings. - Add repository-backed import from configured HTTPS repository settings.
- Add a selected-commit workflow to copy commit SHA/details and create a draft update entry linked to those commits. - Add a selected-commit workflow to copy commit SHA/details and create a draft update entry linked to those commits.

View File

@@ -164,22 +164,6 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/api/developer/release-commits": {
parameters: { parameters: {
query?: never; query?: never;
@@ -260,7 +244,7 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/developer/release-updates/{id}/send-email": { "/api/developer/release-commits/refresh": {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
@@ -269,7 +253,7 @@ export interface paths {
}; };
get?: never; get?: never;
put?: never; put?: never;
post: operations["SocializeApiModulesReleaseCommunicationsHandlersSendDeveloperReleaseUpdateEmailHandler"]; post: operations["SocializeApiModulesReleaseCommunicationsHandlersRefreshDeveloperReleaseCommitsHandler"];
delete?: never; delete?: never;
options?: never; options?: never;
head?: never; head?: never;
@@ -292,6 +276,22 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/developer/release-commits/{sha}/link-first-release": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["SocializeApiModulesReleaseCommunicationsHandlersLinkFirstReleaseCommitsHandler"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/developer/release-commits/{sha}/unlink": { "/api/developer/release-commits/{sha}/unlink": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1471,15 +1471,12 @@ export interface components {
/** Format: guid */ /** Format: guid */
id?: string; id?: string;
title?: string; title?: string;
summary?: string; description?: string;
body?: string | null; titleEn?: string;
category?: string; descriptionEn?: string;
importance?: string; titleFr?: string;
audience?: string; descriptionFr?: string;
status?: string; status?: string;
deploymentLabel?: string | null;
buildVersion?: string | null;
commitRange?: string | null;
/** Format: date-time */ /** Format: date-time */
createdAt?: string; createdAt?: string;
/** Format: date-time */ /** Format: date-time */
@@ -1488,25 +1485,13 @@ export interface components {
publishedAt?: string | null; publishedAt?: string | null;
/** Format: date-time */ /** Format: date-time */
archivedAt?: string | null; archivedAt?: string | null;
/** Format: guid */
manualEmailSentByUserId?: string | null;
/** Format: date-time */
manualEmailSentAt?: string | null;
manualEmailAudience?: string | null;
/** Format: int32 */
manualEmailRecipientCount?: number | null;
isRead?: boolean; isRead?: boolean;
}; };
SocializeApiModulesReleaseCommunicationsHandlersCreateDeveloperReleaseUpdateRequest: { SocializeApiModulesReleaseCommunicationsHandlersCreateDeveloperReleaseUpdateRequest: {
title: string; titleEn: string;
summary: string; descriptionEn: string;
body?: string | null; titleFr: string;
category: string; descriptionFr: string;
importance: string;
audience: string;
deploymentLabel?: string | null;
buildVersion?: string | null;
commitRange?: string | null;
}; };
SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateUnreadSummaryDto: { SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateUnreadSummaryDto: {
/** Format: int32 */ /** Format: int32 */
@@ -1515,15 +1500,6 @@ export interface components {
importantUnreadCount?: number; importantUnreadCount?: number;
updates?: components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateDto"][]; updates?: components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateDto"][];
}; };
SocializeApiModulesReleaseCommunicationsContractsReleaseCommitImportResultDto: {
/** Format: int32 */
importedCount?: number;
/** Format: int32 */
updatedCount?: number;
/** Format: int32 */
skippedCount?: number;
commits?: components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseCommitDto"][];
};
SocializeApiModulesReleaseCommunicationsContractsReleaseCommitDto: { SocializeApiModulesReleaseCommunicationsContractsReleaseCommitDto: {
sha?: string; sha?: string;
shortSha?: string; shortSha?: string;
@@ -1545,52 +1521,32 @@ export interface components {
/** Format: date-time */ /** Format: date-time */
updatedAt?: string; updatedAt?: string;
}; };
SocializeApiModulesReleaseCommunicationsHandlersImportDeveloperReleaseCommitsRequest: { SocializeApiModulesReleaseCommunicationsContractsReleaseCommitRefreshResultDto: {
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 */ /** Format: int32 */
recipientCount?: number; createdCount?: number;
/** Format: date-time */ /** Format: int32 */
sentAt?: string; updatedCount?: number;
testMode?: boolean; /** Format: int32 */
}; skippedCount?: number;
SocializeApiModulesReleaseCommunicationsHandlersSendDeveloperReleaseUpdateEmailRequest: { commits?: components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseCommitDto"][];
testMode?: boolean;
confirmResend?: boolean;
}; };
SocializeApiModulesReleaseCommunicationsHandlersLinkDeveloperReleaseCommitRequest: { SocializeApiModulesReleaseCommunicationsHandlersLinkDeveloperReleaseCommitRequest: {
/** Format: guid */ /** Format: guid */
releaseUpdateId?: string; releaseUpdateId?: string;
}; };
SocializeApiModulesReleaseCommunicationsContractsReleaseCommitBulkLinkResultDto: {
/** Format: int32 */
linkedCount?: number;
};
SocializeApiModulesReleaseCommunicationsHandlersLinkFirstReleaseCommitsRequest: {
/** Format: guid */
releaseUpdateId?: string;
};
SocializeApiModulesReleaseCommunicationsHandlersUpdateDeveloperReleaseUpdateRequest: { SocializeApiModulesReleaseCommunicationsHandlersUpdateDeveloperReleaseUpdateRequest: {
title: string; titleEn: string;
summary: string; descriptionEn: string;
body?: string | null; titleFr: string;
category: string; descriptionFr: string;
importance: string;
audience: string;
deploymentLabel?: string | null;
buildVersion?: string | null;
commitRange?: string | null;
}; };
SocializeApiModulesOrganizationsHandlersOrganizationMemberDto: { SocializeApiModulesOrganizationsHandlersOrganizationMemberDto: {
/** Format: guid */ /** Format: guid */
@@ -2871,44 +2827,6 @@ export interface operations {
}; };
}; };
}; };
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: { SocializeApiModulesReleaseCommunicationsHandlersListDeveloperReleaseCommitsHandler: {
parameters: { parameters: {
query?: never; query?: never;
@@ -3058,20 +2976,14 @@ export interface operations {
}; };
}; };
}; };
SocializeApiModulesReleaseCommunicationsHandlersSendDeveloperReleaseUpdateEmailHandler: { SocializeApiModulesReleaseCommunicationsHandlersRefreshDeveloperReleaseCommitsHandler: {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
path: { path?: never;
id: string;
};
cookie?: never; cookie?: never;
}; };
requestBody: { requestBody?: never;
content: {
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsHandlersSendDeveloperReleaseUpdateEmailRequest"];
};
};
responses: { responses: {
/** @description Success */ /** @description Success */
200: { 200: {
@@ -3079,7 +2991,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateEmailSendResultDto"]; "application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseCommitRefreshResultDto"];
}; };
}; };
/** @description Unauthorized */ /** @description Unauthorized */
@@ -3138,6 +3050,46 @@ export interface operations {
}; };
}; };
}; };
SocializeApiModulesReleaseCommunicationsHandlersLinkFirstReleaseCommitsHandler: {
parameters: {
query?: never;
header?: never;
path: {
sha: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsHandlersLinkFirstReleaseCommitsRequest"];
};
};
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseCommitBulkLinkResultDto"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesReleaseCommunicationsHandlersUnlinkDeveloperReleaseCommitHandler: { SocializeApiModulesReleaseCommunicationsHandlersUnlinkDeveloperReleaseCommitHandler: {
parameters: { parameters: {
query?: never; query?: never;

View File

@@ -1,4 +1,5 @@
@import "tailwindcss"; @import "tailwindcss/theme.css";
@import "tailwindcss/utilities.css";
@custom-variant dark (&:where(.dark, .dark *)); @custom-variant dark (&:where(.dark, .dark *));
@theme inline { @theme inline {
@@ -29,59 +30,6 @@ textarea::placeholder {
opacity: 1; opacity: 1;
} }
.v-application {
background: rgb(var(--v-theme-background)) !important;
color: rgb(var(--v-theme-on-background));
}
.v-card,
.v-sheet,
.v-list,
.v-menu > .v-overlay__content,
.v-dialog > .v-overlay__content {
background-color: rgb(var(--v-theme-surface)) !important;
border: 1px solid rgb(var(--v-theme-border));
}
.v-field {
background-color: rgb(var(--v-theme-control)) !important;
color: rgb(var(--v-theme-on-surface));
}
.v-field:hover {
background-color: rgb(var(--v-theme-control-hover)) !important;
}
.v-field--focused {
background-color: rgb(var(--v-theme-control-focus)) !important;
}
.v-field__outline {
color: rgb(var(--v-theme-border-strong));
}
.v-field--focused .v-field__outline {
color: rgb(var(--v-theme-highlight));
}
.v-field__input,
.v-field-label {
color: rgb(var(--v-theme-on-surface));
}
.v-select .v-field .v-field__input > input,
.v-select .v-field .v-field__input > input::placeholder {
color: transparent !important;
caret-color: transparent;
}
.panel,
[class$='-panel'],
[class$='-card'],
div.card {
border-color: rgb(var(--v-theme-border)) !important;
}
@layer components { @layer components {
.btn { .btn {
@apply min-w-24 w-full; @apply min-w-24 w-full;

View File

@@ -0,0 +1,54 @@
export function formatReleaseDescription(value) {
const lines = String(value ?? '').replace(/\r\n?/g, '\n').split('\n');
const blocks = [];
let paragraphLines = [];
let listItems = [];
function flushParagraph() {
if (!paragraphLines.length) {
return;
}
blocks.push({
type: 'paragraph',
text: paragraphLines.join(' ').trim(),
});
paragraphLines = [];
}
function flushList() {
if (!listItems.length) {
return;
}
blocks.push({
type: 'list',
items: listItems,
});
listItems = [];
}
lines.forEach(line => {
const trimmed = line.trim();
if (!trimmed) {
flushParagraph();
flushList();
return;
}
const bullet = trimmed.match(/^[-*]\s+(.+)$/);
if (bullet) {
flushParagraph();
listItems.push(bullet[1].trim());
return;
}
flushList();
paragraphLines.push(trimmed);
});
flushParagraph();
flushList();
return blocks;
}

View File

@@ -3,17 +3,9 @@ import { defineStore } from 'pinia';
import { useClient } from '@/plugins/api.js'; import { useClient } from '@/plugins/api.js';
const DEFAULT_COMMIT_FILTERS = Object.freeze({ const DEFAULT_COMMIT_FILTERS = Object.freeze({
status: '', inclusion: 'notIncluded',
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', () => { export const useReleaseCommunicationsStore = defineStore('release-communications', () => {
const client = useClient(); const client = useClient();
const updates = ref([]); const updates = ref([]);
@@ -21,11 +13,11 @@ export const useReleaseCommunicationsStore = defineStore('release-communications
const developerUpdates = ref([]); const developerUpdates = ref([]);
const selectedUpdate = ref(null); const selectedUpdate = ref(null);
const commits = ref([]); const commits = ref([]);
const selectedCommitShas = ref([]);
const commitFilters = ref({ ...DEFAULT_COMMIT_FILTERS }); const commitFilters = ref({ ...DEFAULT_COMMIT_FILTERS });
const isLoading = ref(false); const isLoading = ref(false);
const isSaving = ref(false); const isSaving = ref(false);
const isSendingEmail = ref(false); const isRefreshingCommits = ref(false);
const isImporting = ref(false);
const error = ref(null); const error = ref(null);
const unreadCount = computed(() => unreadSummary.value?.unreadCount ?? 0); const unreadCount = computed(() => unreadSummary.value?.unreadCount ?? 0);
@@ -35,40 +27,15 @@ export const useReleaseCommunicationsStore = defineStore('release-communications
); );
const filteredCommits = computed(() => { const filteredCommits = computed(() => {
const query = commitFilters.value.search.trim().toLowerCase();
const author = commitFilters.value.author.trim().toLowerCase();
return commits.value.filter(commit => { return commits.value.filter(commit => {
if (commitFilters.value.status && commit.communicationStatus !== commitFilters.value.status) { if (commitFilters.value.inclusion === 'included' && !commit.releaseUpdateId) {
return false; return false;
} }
if (commitFilters.value.updateId && commit.releaseUpdateId !== commitFilters.value.updateId) { if (commitFilters.value.inclusion === 'notIncluded' && commit.releaseUpdateId) {
return false; 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; return true;
}); });
}); });
@@ -159,28 +126,19 @@ export const useReleaseCommunicationsStore = defineStore('release-communications
return response.data; 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() { async function loadCommits() {
const response = await client.get('/api/developer/release-commits'); const response = await client.get('/api/developer/release-commits');
commits.value = response.data ?? []; commits.value = response.data ?? [];
} }
async function importCommits(payload) { async function refreshCommits() {
isImporting.value = true; isRefreshingCommits.value = true;
try { try {
const response = await client.post('/api/developer/release-commits/import', payload); const response = await client.post('/api/developer/release-commits/refresh');
await loadCommits(); await loadCommits();
return response.data; return response.data;
} finally { } finally {
isImporting.value = false; isRefreshingCommits.value = false;
} }
} }
@@ -194,6 +152,12 @@ export const useReleaseCommunicationsStore = defineStore('release-communications
await Promise.all([loadCommits(), loadDeveloperUpdates()]); await Promise.all([loadCommits(), loadDeveloperUpdates()]);
} }
async function linkFirstReleaseCommits(anchorSha, releaseUpdateId) {
const response = await client.post(`/api/developer/release-commits/${anchorSha}/link-first-release`, { releaseUpdateId });
await Promise.all([loadCommits(), loadDeveloperUpdates()]);
return response.data;
}
async function unlinkCommit(sha) { async function unlinkCommit(sha) {
await client.post(`/api/developer/release-commits/${sha}/unlink`); await client.post(`/api/developer/release-commits/${sha}/unlink`);
await loadCommits(); await loadCommits();
@@ -213,12 +177,23 @@ export const useReleaseCommunicationsStore = defineStore('release-communications
commitFilters.value = { ...DEFAULT_COMMIT_FILTERS }; commitFilters.value = { ...DEFAULT_COMMIT_FILTERS };
} }
function setCommitSelected(sha, selected) {
selectedCommitShas.value = selected
? [...new Set([...selectedCommitShas.value, sha])]
: selectedCommitShas.value.filter(selectedSha => selectedSha !== sha);
}
function clearSelectedCommits() {
selectedCommitShas.value = [];
}
return { return {
updates, updates,
unreadSummary, unreadSummary,
developerUpdates, developerUpdates,
selectedUpdate, selectedUpdate,
commits, commits,
selectedCommitShas,
commitFilters, commitFilters,
filteredCommits, filteredCommits,
unreadCount, unreadCount,
@@ -226,8 +201,7 @@ export const useReleaseCommunicationsStore = defineStore('release-communications
unreviewedCommitCount, unreviewedCommitCount,
isLoading, isLoading,
isSaving, isSaving,
isSendingEmail, isRefreshingCommits,
isImporting,
error, error,
loadUserUpdates, loadUserUpdates,
loadUnreadSummary, loadUnreadSummary,
@@ -238,14 +212,16 @@ export const useReleaseCommunicationsStore = defineStore('release-communications
saveDeveloperUpdate, saveDeveloperUpdate,
publishUpdate, publishUpdate,
archiveUpdate, archiveUpdate,
sendUpdateEmail,
loadCommits, loadCommits,
importCommits, refreshCommits,
linkCommit, linkCommit,
linkCommitsToUpdate, linkCommitsToUpdate,
linkFirstReleaseCommits,
unlinkCommit, unlinkCommit,
markCommitInternalOnly, markCommitInternalOnly,
ignoreCommit, ignoreCommit,
resetCommitFilters, resetCommitFilters,
setCommitSelected,
clearSelectedCommits,
}; };
}); });

View File

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

View File

@@ -2,9 +2,10 @@
import { computed, onMounted } from 'vue'; import { computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { formatReleaseDescription } from '@/features/release-communications/formatReleaseDescription.js';
import { useReleaseCommunicationsStore } from '@/features/release-communications/stores/releaseCommunicationsStore.js'; import { useReleaseCommunicationsStore } from '@/features/release-communications/stores/releaseCommunicationsStore.js';
const { t } = useI18n(); const { locale, t } = useI18n();
const route = useRoute(); const route = useRoute();
const store = useReleaseCommunicationsStore(); const store = useReleaseCommunicationsStore();
@@ -12,31 +13,46 @@
onMounted(async () => { onMounted(async () => {
await store.loadUserUpdates(); await store.loadUserUpdates();
if (highlightedId.value) { if (store.updates.some(update => !update.isRead)) {
await store.markRead(highlightedId.value); await store.markAllRead();
} }
}); });
function formatDate(value) { function formatDate(value) {
return value ? new Date(value).toLocaleString() : ''; if (!value) {
return '';
}
return new Intl.DateTimeFormat(locale.value, {
dateStyle: 'long',
}).format(new Date(value));
}
function updateTitle(update) {
return locale.value.startsWith('fr')
? update.titleFr || update.title
: update.titleEn || update.title;
}
function updateDescription(update) {
return locale.value.startsWith('fr')
? update.descriptionFr || update.description
: update.descriptionEn || update.description;
}
function updateDescriptionBlocks(update) {
return formatReleaseDescription(updateDescription(update));
} }
</script> </script>
<template> <template>
<section class="updates-page"> <section class="updates-page">
<header class="updates-header"> <header class="page-header">
<div> <div>
<div class="eyebrow">{{ t('releaseCommunications.user.eyebrow') }}</div> <div class="eyebrow">{{ t('releaseCommunications.user.eyebrow') }}</div>
<h1>{{ t('releaseCommunications.user.title') }}</h1> <h1>{{ t('releaseCommunications.user.title') }}</h1>
<p>{{ t('releaseCommunications.user.description') }}</p> <p>{{ t('releaseCommunications.user.description') }}</p>
</div> </div>
<v-btn
variant="outlined"
:disabled="!store.unreadCount"
@click="store.markAllRead"
>
{{ t('releaseCommunications.user.markAllRead') }}
</v-btn>
</header> </header>
<div <div
@@ -55,21 +71,25 @@
:key="update.id" :key="update.id"
class="update-entry" class="update-entry"
:class="{ 'update-entry-unread': !update.isRead, 'update-entry-highlight': update.id === highlightedId }" :class="{ 'update-entry-unread': !update.isRead, 'update-entry-highlight': update.id === highlightedId }"
@click="!update.isRead && store.markRead(update.id)"
> >
<div class="update-meta"> <h2>{{ updateTitle(update) }}</h2>
<span>{{ update.category }}</span> <div class="release-description">
<span>{{ update.importance }}</span> <template
<time>{{ formatDate(update.publishedAt) }}</time> v-for="(block, index) in updateDescriptionBlocks(update)"
</div> :key="index"
<h2>{{ update.title }}</h2> >
<p>{{ update.summary }}</p> <p v-if="block.type === 'paragraph'">{{ block.text }}</p>
<div <ul v-else>
v-if="update.body" <li
class="update-body" v-for="item in block.items"
> :key="item"
{{ update.body }} >
{{ item }}
</li>
</ul>
</template>
</div> </div>
<time>{{ formatDate(update.publishedAt) }}</time>
</article> </article>
<div <div
@@ -83,81 +103,83 @@
</template> </template>
<style scoped> <style scoped>
@reference "@/assets/main.css";
.updates-page { .updates-page {
display: grid; @apply mx-auto flex w-full max-w-6xl flex-col gap-5 px-5 py-8 md:px-8;
gap: 20px;
padding: 24px;
} }
.updates-header { .page-header {
display: flex; @apply flex flex-col justify-between gap-4 md:flex-row md:items-start;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
} }
.eyebrow, .eyebrow {
.update-meta { @apply text-xs font-bold uppercase tracking-[0.22em];
color: rgb(var(--v-theme-primary)); color: #0f766e;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
} }
.updates-header h1 { .page-header h1 {
margin: 4px 0; @apply mt-2 text-3xl font-black md:text-4xl;
font-size: 1.75rem; color: #172033;
} }
.updates-header p { .page-header p {
margin: 0; @apply mt-2 max-w-3xl text-sm leading-6;
color: #64748b; color: #526178;
} }
.updates-list { .updates-list {
display: grid; display: grid;
gap: 12px;
} }
.update-entry { .update-entry {
border: 1px solid #d8dee8; display: grid;
border-radius: 8px; gap: 8px;
background: #fff; border-bottom: 1px solid #d8dee8;
padding: 16px; padding: 18px 0;
} }
.update-entry-unread { .update-entry:first-child {
border-color: rgb(var(--v-theme-primary)); padding-top: 0;
box-shadow: inset 3px 0 0 rgb(var(--v-theme-primary)); }
cursor: pointer;
.update-entry:last-of-type {
border-bottom: 0;
} }
.update-entry-highlight { .update-entry-highlight {
outline: 2px solid rgb(var(--v-theme-primary)); box-shadow: inset 3px 0 0 rgb(var(--v-theme-primary));
} padding-left: 12px;
.update-meta {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 8px;
color: #64748b;
} }
.update-entry h2 { .update-entry h2 {
margin: 0 0 6px;
font-size: 1.1rem;
}
.update-entry p {
margin: 0; margin: 0;
color: #334155; font-size: 1.1rem;
font-weight: 800;
color: #172033;
} }
.update-body { .release-description {
margin-top: 12px; display: grid;
color: #475569; gap: 8px;
white-space: pre-line; color: #334155;
font-size: 0.95rem;
line-height: 1.55;
}
.release-description p,
.release-description ul {
margin: 0;
}
.release-description ul {
padding-left: 20px;
}
.update-entry time {
color: #64748b;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
} }
.page-message { .page-message {

View File

@@ -3,20 +3,25 @@
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useAuthStore } from '@/features/auth/stores/authStore.js'; import { useAuthStore } from '@/features/auth/stores/authStore.js';
import { useReleaseCommunicationsStore } from '@/features/release-communications/stores/releaseCommunicationsStore.js';
import WorkspaceSelector from './WorkspaceSelector.vue'; import WorkspaceSelector from './WorkspaceSelector.vue';
import { import {
mdiCalendar, mdiCalendar,
mdiChevronDown, mdiChevronDown,
mdiCogOutline, mdiCogOutline,
mdiEyeOffOutline,
mdiFlagVariantOutline,
mdiFormatListBulleted, mdiFormatListBulleted,
mdiLogin, mdiLogin,
mdiPlus, mdiPlus,
mdiRefresh,
mdiTable, mdiTable,
} from '@mdi/js'; } from '@mdi/js';
const route = useRoute(); const route = useRoute();
const { t } = useI18n(); const { t } = useI18n();
const authStore = useAuthStore(); const authStore = useAuthStore();
const releaseCommunicationsStore = useReleaseCommunicationsStore();
const isContentViewMenuOpen = ref(false); const isContentViewMenuOpen = ref(false);
const contentViewActions = computed(() => { const contentViewActions = computed(() => {
@@ -104,6 +109,66 @@
icon: mdiPlus, icon: mdiPlus,
route: { name: 'channels', query: { create: 'true' } }, route: { name: 'channels', query: { create: 'true' } },
}]; }];
case 'developer-release-notes':
return route.query.tab === 'release-notes'
? []
: [
{
key: 'refresh-release-commits',
label: t('releaseCommunications.commits.refresh'),
icon: mdiRefresh,
route: {
name: 'developer-release-notes',
query: {
...route.query,
tab: 'git-log',
refreshCommits: 'true',
},
},
},
{
key: 'exclude-release-commits',
label: t('releaseCommunications.commits.exclude'),
icon: mdiEyeOffOutline,
disabled: releaseCommunicationsStore.selectedCommitShas.length === 0,
route: {
name: 'developer-release-notes',
query: {
...route.query,
tab: 'git-log',
excludeCommits: 'true',
},
},
},
{
key: 'create-first-release',
label: t('releaseCommunications.developer.createFirstRelease'),
icon: mdiFlagVariantOutline,
disabled: releaseCommunicationsStore.selectedCommitShas.length !== 1,
route: {
name: 'developer-release-notes',
query: {
...route.query,
tab: 'git-log',
createFirstRelease: 'true',
},
},
},
{
key: 'create-release-note',
label: t('releaseCommunications.developer.createReleaseNote'),
icon: mdiPlus,
disabled: releaseCommunicationsStore.selectedCommitShas.length === 0,
route: {
name: 'developer-release-notes',
query: {
...route.query,
tab: 'git-log',
createReleaseNote: 'true',
},
},
},
];
case 'workspace-settings': case 'workspace-settings':
case 'settings-user-information': case 'settings-user-information':
case 'settings-workspaces': case 'settings-workspaces':
@@ -179,17 +244,30 @@
</div> </div>
</div> </div>
<router-link <template
v-for="action in appBarActions" v-for="action in appBarActions"
:key="action.key" :key="action.key"
:to="action.route"
class="menu-action-link"
> >
<button class="menu-item-action"> <button
v-if="action.disabled"
class="menu-item-action"
type="button"
disabled
>
<v-icon :icon="action.icon" /> <v-icon :icon="action.icon" />
<span class="label">{{ action.label }}</span> <span class="label">{{ action.label }}</span>
</button> </button>
</router-link> <router-link
v-else
:to="action.route"
class="menu-action-link"
>
<button class="menu-item-action">
<v-icon :icon="action.icon" />
<span class="label">{{ action.label }}</span>
</button>
</router-link>
</template>
</div> </div>
</div> </div>
</nav> </nav>
@@ -256,6 +334,16 @@
color: #fffaf2; color: #fffaf2;
} }
.menu-item-action:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.menu-item-action:disabled:hover {
background: rgba(255, 255, 255, 0.8);
color: #172033;
}
.view-selector-option { .view-selector-option {
@apply flex min-h-11 w-full items-center gap-3 rounded-[0.75rem] px-3 text-left text-sm font-semibold transition; @apply flex min-h-11 w-full items-center gap-3 rounded-[0.75rem] px-3 text-left text-sm font-semibold transition;
color: #172033; color: #172033;

View File

@@ -52,17 +52,20 @@
const collapsedSearchInputRef = ref(null); const collapsedSearchInputRef = ref(null);
const collapsedSearchPanelStyle = ref({}); const collapsedSearchPanelStyle = ref({});
const filterVisibleLinks = links =>
links.filter(link => !link.roles || authStore.hasAnyRole(link.roles));
const primaryLinks = [ const primaryLinks = [
{ to: '/app/dashboard', labelKey: 'nav.overview', icon: mdiHomeOutline }, { to: '/app/dashboard', labelKey: 'nav.overview', icon: mdiHomeOutline },
{ to: '/app/media-library', labelKey: 'nav.mediaLibrary', icon: mdiImageMultipleOutline }, { to: '/app/media-library', labelKey: 'nav.mediaLibrary', icon: mdiImageMultipleOutline },
{ to: '/app/developer/release-notes', labelKey: 'nav.releaseNotes', icon: mdiSourceCommit, roles: ['developer'], badge: 'commits' },
];
const bottomLinks = [
{ to: '/app/updates', labelKey: 'nav.whatsNew', icon: mdiBullhornOutline, badge: 'updates' }, { to: '/app/updates', labelKey: 'nav.whatsNew', icon: mdiBullhornOutline, badge: 'updates' },
{ to: '/app/feedback', labelKey: 'nav.feedbackReview', icon: mdiBugOutline, roles: ['developer'] }, { to: '/app/feedback', labelKey: 'nav.feedbackReview', icon: mdiBugOutline, roles: ['developer'] },
{ to: '/app/developer/updates', labelKey: 'nav.releaseUpdates', icon: mdiBullhornOutline, roles: ['developer'] },
{ to: '/app/developer/release-commits', labelKey: 'nav.releaseCommits', icon: mdiSourceCommit, roles: ['developer'], badge: 'commits' },
]; ];
const visiblePrimaryLinks = computed(() => const visiblePrimaryLinks = computed(() => filterVisibleLinks(primaryLinks));
primaryLinks.filter(link => !link.roles || authStore.hasAnyRole(link.roles)) const visibleBottomLinks = computed(() => filterVisibleLinks(bottomLinks));
);
const openSections = ref({ const openSections = ref({
channels: false, channels: false,
@@ -305,11 +308,14 @@
:title="!isExpanded ? 'Search' : null" :title="!isExpanded ? 'Search' : null"
@click="openCollapsedSearch" @click="openCollapsedSearch"
> >
<v-icon
:icon="mdiMagnify"
class="sidebar-search-icon"
/>
<v-text-field <v-text-field
v-if="isExpanded" v-if="isExpanded"
v-model="searchQuery" v-model="searchQuery"
class="sidebar-search-input" class="sidebar-search-input"
:prepend-inner-icon="mdiMagnify"
placeholder="Search" placeholder="Search"
density="compact" density="compact"
variant="plain" variant="plain"
@@ -329,11 +335,14 @@
v-if="!isExpanded" v-if="!isExpanded"
class="sidebar-search sidebar-search-panel-input" class="sidebar-search sidebar-search-panel-input"
> >
<v-icon
:icon="mdiMagnify"
class="sidebar-search-icon"
/>
<v-text-field <v-text-field
ref="collapsedSearchInputRef" ref="collapsedSearchInputRef"
v-model="searchQuery" v-model="searchQuery"
class="sidebar-search-input" class="sidebar-search-input"
:prepend-inner-icon="mdiMagnify"
placeholder="Search" placeholder="Search"
density="compact" density="compact"
variant="plain" variant="plain"
@@ -456,7 +465,7 @@
</div> </div>
</div> </div>
<div class="sidebar-section"> <div class="sidebar-section sidebar-primary-links">
<router-link <router-link
v-for="link in visiblePrimaryLinks" v-for="link in visiblePrimaryLinks"
:key="link.to" :key="link.to"
@@ -655,6 +664,36 @@
</div> </div>
<div
v-if="authStore.isAuthenticated && visibleBottomLinks.length"
class="sidebar-section sidebar-bottom-links"
>
<router-link
v-for="link in visibleBottomLinks"
:key="link.to"
:to="link.to"
class="sidebar-link"
active-class="sidebar-link-active"
:title="!isExpanded ? t(link.labelKey) : null"
>
<span class="sidebar-link-icon-wrap">
<v-icon :icon="link.icon" />
<span
v-if="link.badge === 'updates' && releaseCommunicationsStore.unreadCount"
class="sidebar-notification-badge"
>
{{ Math.min(releaseCommunicationsStore.unreadCount, 9) }}
</span>
</span>
<span
v-if="isExpanded"
class="sidebar-link-label"
>
{{ t(link.labelKey) }}
</span>
</router-link>
</div>
<SidebarUserMenu <SidebarUserMenu
v-if="authStore.isAuthenticated" v-if="authStore.isAuthenticated"
:is-expanded="isExpanded" :is-expanded="isExpanded"
@@ -724,7 +763,11 @@
} }
.sidebar-utilities { .sidebar-utilities {
@apply gap-3 pb-1; @apply gap-3;
}
.sidebar-primary-links {
margin-top: -0.5rem;
} }
.sidebar-search-wrap, .sidebar-search-wrap,
@@ -747,6 +790,7 @@
.sidebar-search-icon { .sidebar-search-icon {
@apply h-5 w-5 flex-shrink-0 text-xl; @apply h-5 w-5 flex-shrink-0 text-xl;
color: #526178;
} }
.sidebar-search-input { .sidebar-search-input {
@@ -755,6 +799,10 @@
outline: none; outline: none;
} }
.sidebar-search-input :deep(.v-field__input) {
@apply min-h-0 p-0;
}
.sidebar-search-input::placeholder { .sidebar-search-input::placeholder {
color: #7a8799; color: #7a8799;
} }
@@ -866,6 +914,10 @@
@apply flex flex-col gap-2; @apply flex flex-col gap-2;
} }
.sidebar-bottom-links {
@apply flex-shrink-0 pb-4;
}
.sidebar-section-header { .sidebar-section-header {
@apply flex items-center gap-2; @apply flex items-center gap-2;
} }
@@ -916,6 +968,7 @@
transform: rotate(180deg); transform: rotate(180deg);
} }
.sidebar-search :deep(.v-icon),
.sidebar-link :deep(.v-icon), .sidebar-link :deep(.v-icon),
.sidebar-section-action :deep(.v-icon) { .sidebar-section-action :deep(.v-icon) {
@apply h-5 w-5 flex-shrink-0 text-xl; @apply h-5 w-5 flex-shrink-0 text-xl;

View File

@@ -562,7 +562,7 @@
"feedbackReview": "Feedback Review", "feedbackReview": "Feedback Review",
"whatsNew": "What's New", "whatsNew": "What's New",
"releaseUpdates": "Release Updates", "releaseUpdates": "Release Updates",
"releaseCommits": "Release Commits", "releaseNotes": "Release Notes",
"channels": "Channels", "channels": "Channels",
"campaigns": "Campaigns", "campaigns": "Campaigns",
"reviewQueue": "Review Queue", "reviewQueue": "Review Queue",
@@ -595,9 +595,17 @@
"feedbackReporterCommented": "Reporter replied" "feedbackReporterCommented": "Reporter replied"
} }
}, },
"releaseCommunications": { "releaseCommunications": {
"summary": "Summary", "summary": "Summary",
"body": "Body", "description": "Description",
"noteTitle": "Title",
"english": "English",
"french": "French",
"titleEn": "English title",
"descriptionEn": "English description",
"titleFr": "French title",
"descriptionFr": "French description",
"body": "Body",
"category": "Category", "category": "Category",
"importance": "Importance", "importance": "Importance",
"audience": "Audience", "audience": "Audience",
@@ -618,44 +626,59 @@
"developer": { "developer": {
"eyebrow": "SaaS operator", "eyebrow": "SaaS operator",
"title": "Release updates", "title": "Release updates",
"description": "Create, publish, and archive curated product updates.",
"creationTitle": "Release Note",
"creationDescription": "Draft a release note from selected commits or edit an existing release.",
"createReleaseNote": "Create Release Note",
"createFirstRelease": "Create First Release",
"pastReleases": "Past releases",
"pastReleasesDescription": "Published, archived, and draft release notes.",
"noReleaseNotes": "No release notes yet.",
"newUpdate": "New update", "newUpdate": "New update",
"publish": "Publish", "publish": "Publish",
"archive": "Archive", "archive": "Archive",
"pushEmail": "Push email",
"testMode": "Send to me only",
"confirmResend": "Confirm resend",
"sendEmail": "Send email",
"confirmEmail": "Send this release update by email?",
"emailResult": "Email sent to {count} recipient(s).",
"linkedCommits": "Linked commits", "linkedCommits": "Linked commits",
"noLinkedCommits": "No commits linked to this update yet." "noLinkedCommits": "No commits linked to this update yet."
}, },
"commits": { "commits": {
"eyebrow": "SaaS operator", "eyebrow": "SaaS operator",
"title": "Release commits", "title": "Release notes",
"description": "Import shipped commits and reconcile them with curated update entries.", "description": "Review pending commits and maintain the release note history.",
"gitLogTab": "Changes",
"releaseNotesTab": "History",
"refresh": "Refresh",
"exclude": "Exclude",
"unreviewed": "unreviewed", "unreviewed": "unreviewed",
"branch": "Branch or ref", "branch": "Branch or ref",
"limit": "Limit", "limit": "Limit",
"since": "Since", "since": "Since",
"until": "Until", "until": "Until",
"fetch": "Fetch commits", "fetch": "Fetch commits",
"importJson": "Commit JSON payload",
"import": "Import commits",
"importResult": "Imported {imported}, updated {updated}, skipped {skipped}.", "importResult": "Imported {imported}, updated {updated}, skipped {skipped}.",
"search": "Search", "search": "Search",
"status": "Status", "status": "Status",
"linkedUpdate": "Linked update", "linkedUpdate": "Linked update",
"releaseNote": "Release note",
"notIncluded": "Not included",
"inclusion": {
"label": "Release note status",
"notIncluded": "Not in a release note",
"included": "In a release note",
"all": "All commits"
},
"author": "Author", "author": "Author",
"sha": "SHA",
"commit": "Commit",
"committed": "Committed",
"actions": "Actions",
"empty": "No commits match the current filters.",
"clear": "Clear", "clear": "Clear",
"link": "Update", "link": "Update",
"selected": "{count} selected", "selected": "{count} selected",
"selectCommit": "Select commit", "selectCommit": "Select commit",
"copy": "Copy",
"copySelected": "Copy selected",
"copied": "Copied.", "copied": "Copied.",
"clearSelection": "Clear selection", "createUpdate": "Create release note",
"createUpdate": "Create update entry", "selectedCommits": "Selected commits",
"internalOnly": "Internal only", "internalOnly": "Internal only",
"ignore": "Ignore" "ignore": "Ignore"
} }

View File

@@ -562,7 +562,7 @@
"feedbackReview": "Revue feedback", "feedbackReview": "Revue feedback",
"whatsNew": "Nouveautés", "whatsNew": "Nouveautés",
"releaseUpdates": "Mises à jour", "releaseUpdates": "Mises à jour",
"releaseCommits": "Commits release", "releaseNotes": "Notes de version",
"channels": "Canaux", "channels": "Canaux",
"campaigns": "Campagnes", "campaigns": "Campagnes",
"reviewQueue": "File de révision", "reviewQueue": "File de révision",
@@ -595,9 +595,17 @@
"feedbackReporterCommented": "Réponse du rapporteur" "feedbackReporterCommented": "Réponse du rapporteur"
} }
}, },
"releaseCommunications": { "releaseCommunications": {
"summary": "Résumé", "summary": "Résumé",
"body": "Détail", "description": "Description",
"noteTitle": "Titre",
"english": "Anglais",
"french": "Français",
"titleEn": "Titre anglais",
"descriptionEn": "Description anglaise",
"titleFr": "Titre français",
"descriptionFr": "Description française",
"body": "Détail",
"category": "Catégorie", "category": "Catégorie",
"importance": "Importance", "importance": "Importance",
"audience": "Audience", "audience": "Audience",
@@ -618,44 +626,59 @@
"developer": { "developer": {
"eyebrow": "Opérateur SaaS", "eyebrow": "Opérateur SaaS",
"title": "Mises à jour release", "title": "Mises à jour release",
"description": "Créez, publiez et archivez les mises à jour produit rédigées.",
"creationTitle": "Note de release",
"creationDescription": "Rédigez une note depuis les commits sélectionnés ou modifiez une release existante.",
"createReleaseNote": "Créer une note de release",
"createFirstRelease": "Créer la première release",
"pastReleases": "Releases passées",
"pastReleasesDescription": "Notes de release publiées, archivées et brouillons.",
"noReleaseNotes": "Aucune note de release pour le moment.",
"newUpdate": "Nouvelle mise à jour", "newUpdate": "Nouvelle mise à jour",
"publish": "Publier", "publish": "Publier",
"archive": "Archiver", "archive": "Archiver",
"pushEmail": "Email push",
"testMode": "M'envoyer seulement",
"confirmResend": "Confirmer le renvoi",
"sendEmail": "Envoyer email",
"confirmEmail": "Envoyer cette mise à jour par email?",
"emailResult": "Email envoyé à {count} destinataire(s).",
"linkedCommits": "Commits liés", "linkedCommits": "Commits liés",
"noLinkedCommits": "Aucun commit lié à cette mise à jour." "noLinkedCommits": "Aucun commit lié à cette mise à jour."
}, },
"commits": { "commits": {
"eyebrow": "Opérateur SaaS", "eyebrow": "Opérateur SaaS",
"title": "Commits release", "title": "Notes de release",
"description": "Importez les commits livrés et associez-les aux mises à jour rédigées.", "description": "Révisez les commits en attente et gérez l'historique des notes de release.",
"gitLogTab": "Changements",
"releaseNotesTab": "Historique",
"refresh": "Actualiser",
"exclude": "Exclure",
"unreviewed": "non révisés", "unreviewed": "non révisés",
"branch": "Branche ou ref", "branch": "Branche ou ref",
"limit": "Limite", "limit": "Limite",
"since": "Depuis", "since": "Depuis",
"until": "Jusqu'au", "until": "Jusqu'au",
"fetch": "Récupérer commits", "fetch": "Récupérer commits",
"importJson": "Payload JSON de commits",
"import": "Importer commits",
"importResult": "{imported} importés, {updated} mis à jour, {skipped} ignorés.", "importResult": "{imported} importés, {updated} mis à jour, {skipped} ignorés.",
"search": "Recherche", "search": "Recherche",
"status": "Statut", "status": "Statut",
"linkedUpdate": "Mise à jour liée", "linkedUpdate": "Mise à jour liée",
"releaseNote": "Note de release",
"notIncluded": "Non inclus",
"inclusion": {
"label": "Statut de note",
"notIncluded": "Non inclus dans une note",
"included": "Inclus dans une note",
"all": "Tous les commits"
},
"author": "Auteur", "author": "Auteur",
"sha": "SHA",
"commit": "Commit",
"committed": "Commité",
"actions": "Actions",
"empty": "Aucun commit ne correspond aux filtres actuels.",
"clear": "Effacer", "clear": "Effacer",
"link": "Mise à jour", "link": "Mise à jour",
"selected": "{count} sélectionnés", "selected": "{count} sélectionnés",
"selectCommit": "Sélectionner le commit", "selectCommit": "Sélectionner le commit",
"copy": "Copier",
"copySelected": "Copier sélection",
"copied": "Copié.", "copied": "Copié.",
"clearSelection": "Effacer sélection", "createUpdate": "Créer la note de release",
"createUpdate": "Créer une entrée", "selectedCommits": "Commits sélectionnés",
"internalOnly": "Interne seulement", "internalOnly": "Interne seulement",
"ignore": "Ignorer" "ignore": "Ignorer"
} }

View File

@@ -2,34 +2,17 @@ import { createApp } from 'vue';
import App from './App.vue'; import App from './App.vue';
import router from '@/router/router.js'; import router from '@/router/router.js';
import { createPinia } from 'pinia'; import { createPinia } from 'pinia';
import './assets/main.css';
import 'vuetify/styles'; import 'vuetify/styles';
import { createVuetify } from 'vuetify'; import { createVuetify } from 'vuetify';
import { aliases, mdi } from 'vuetify/iconsets/mdi-svg'; import { aliases, mdi } from 'vuetify/iconsets/mdi-svg';
import { import * as components from 'vuetify/components';
VAlert, import * as directives from 'vuetify/directives';
VApp,
VBtn,
VCheckbox,
VCheckboxBtn,
VDialog,
VFileInput,
VForm,
VIcon,
VProgressCircular,
VProgressLinear,
VRadio,
VRadioGroup,
VSelect,
VSnackbar,
VTextarea,
VTextField,
} from 'vuetify/components';
import vueGoogleOauth from 'vue3-google-login'; import vueGoogleOauth from 'vue3-google-login';
import { useAuthStore } from '@/features/auth/stores/authStore.js'; import { useAuthStore } from '@/features/auth/stores/authStore.js';
import { useUserProfileStore } from '@/features/user-profile/stores/userProfileStore.js'; import { useUserProfileStore } from '@/features/user-profile/stores/userProfileStore.js';
import Toast, { POSITION } from 'vue-toastification'; import Toast, { POSITION } from 'vue-toastification';
import 'vue-toastification/dist/index.css'; import 'vue-toastification/dist/index.css';
import './assets/main.css';
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js'; import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
import { useReviewQueueStore } from '@/features/reviews/stores/reviewQueueStore.js'; import { useReviewQueueStore } from '@/features/reviews/stores/reviewQueueStore.js';
import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js'; import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js';
@@ -44,26 +27,8 @@ import { createHead } from '@vueuse/head';
import { socializeTheme } from '@/plugins/theme.js'; import { socializeTheme } from '@/plugins/theme.js';
const vuetify = createVuetify({ const vuetify = createVuetify({
components: { components,
VDialog, directives,
VApp,
VBtn,
VCheckbox,
VCheckboxBtn,
VFileInput,
VProgressLinear,
VProgressCircular,
VIcon,
VRadio,
VRadioGroup,
VSelect,
VTextField,
VSnackbar,
VForm,
VTextarea,
VAlert,
},
directives: {},
icons: { icons: {
defaultSet: 'mdi', defaultSet: 'mdi',
aliases, aliases,

View File

@@ -34,7 +34,6 @@ const MyFeedbackDetailView = () => import('@/features/feedback/views/MyFeedbackD
const DeveloperFeedbackListView = () => import('@/features/feedback/views/DeveloperFeedbackListView.vue'); const DeveloperFeedbackListView = () => import('@/features/feedback/views/DeveloperFeedbackListView.vue');
const DeveloperFeedbackDetailView = () => import('@/features/feedback/views/DeveloperFeedbackDetailView.vue'); const DeveloperFeedbackDetailView = () => import('@/features/feedback/views/DeveloperFeedbackDetailView.vue');
const UpdatesView = () => import('@/features/release-communications/views/UpdatesView.vue'); const 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 DeveloperReleaseCommitsView = () => import('@/features/release-communications/views/DeveloperReleaseCommitsView.vue');
const routes = [ const routes = [
@@ -135,12 +134,17 @@ const routes = [
{ {
path: '/app/developer/updates', path: '/app/developer/updates',
name: 'developer-release-updates', name: 'developer-release-updates',
component: DeveloperUpdatesView, redirect: { name: 'developer-release-notes' },
meta: { requiresAuth: true, roles: ['developer'] }, meta: { requiresAuth: true, roles: ['developer'] },
}, },
{ {
path: '/app/developer/release-commits', path: '/app/developer/release-commits',
name: 'developer-release-commits', redirect: { name: 'developer-release-notes' },
meta: { requiresAuth: true, roles: ['developer'] },
},
{
path: '/app/developer/release-notes',
name: 'developer-release-notes',
component: DeveloperReleaseCommitsView, component: DeveloperReleaseCommitsView,
meta: { requiresAuth: true, roles: ['developer'] }, meta: { requiresAuth: true, roles: ['developer'] },
}, },

View File

@@ -7,7 +7,7 @@
}, },
"servers": [ "servers": [
{ {
"url": "http://127.0.0.1:5080" "url": "http://localhost:5080"
} }
], ],
"paths": { "paths": {
@@ -659,53 +659,6 @@
] ]
} }
}, },
"/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": { "/api/developer/release-commits": {
"get": { "get": {
"tags": [ "tags": [
@@ -873,43 +826,20 @@
] ]
} }
}, },
"/api/developer/release-updates/{id}/send-email": { "/api/developer/release-commits/refresh": {
"post": { "post": {
"tags": [ "tags": [
"Release Communications", "Release Communications",
"Api" "Api"
], ],
"operationId": "SocializeApiModulesReleaseCommunicationsHandlersSendDeveloperReleaseUpdateEmailHandler", "operationId": "SocializeApiModulesReleaseCommunicationsHandlersRefreshDeveloperReleaseCommitsHandler",
"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": { "responses": {
"200": { "200": {
"description": "Success", "description": "Success",
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateEmailSendResultDto" "$ref": "#/components/schemas/SocializeApiModulesReleaseCommunicationsContractsReleaseCommitRefreshResultDto"
} }
} }
} }
@@ -987,6 +917,63 @@
] ]
} }
}, },
"/api/developer/release-commits/{sha}/link-first-release": {
"post": {
"tags": [
"Release Communications",
"Api"
],
"operationId": "SocializeApiModulesReleaseCommunicationsHandlersLinkFirstReleaseCommitsHandler",
"parameters": [
{
"name": "sha",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"x-name": "LinkFirstReleaseCommitsRequest",
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SocializeApiModulesReleaseCommunicationsHandlersLinkFirstReleaseCommitsRequest"
}
}
},
"required": true,
"x-position": 1
},
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SocializeApiModulesReleaseCommunicationsContractsReleaseCommitBulkLinkResultDto"
}
}
}
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
}
},
"security": [
{
"JWTBearerAuth": [
"developer"
]
}
]
}
},
"/api/developer/release-commits/{sha}/unlink": { "/api/developer/release-commits/{sha}/unlink": {
"post": { "post": {
"tags": [ "tags": [
@@ -4946,37 +4933,24 @@
"title": { "title": {
"type": "string" "type": "string"
}, },
"summary": { "description": {
"type": "string" "type": "string"
}, },
"body": { "titleEn": {
"type": "string",
"nullable": true
},
"category": {
"type": "string" "type": "string"
}, },
"importance": { "descriptionEn": {
"type": "string" "type": "string"
}, },
"audience": { "titleFr": {
"type": "string"
},
"descriptionFr": {
"type": "string" "type": "string"
}, },
"status": { "status": {
"type": "string" "type": "string"
}, },
"deploymentLabel": {
"type": "string",
"nullable": true
},
"buildVersion": {
"type": "string",
"nullable": true
},
"commitRange": {
"type": "string",
"nullable": true
},
"createdAt": { "createdAt": {
"type": "string", "type": "string",
"format": "date-time" "format": "date-time"
@@ -4995,25 +4969,6 @@
"format": "date-time", "format": "date-time",
"nullable": true "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": { "isRead": {
"type": "boolean" "type": "boolean"
} }
@@ -5023,66 +4978,35 @@
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": [ "required": [
"title", "titleEn",
"summary", "descriptionEn",
"category", "titleFr",
"importance", "descriptionFr"
"audience"
], ],
"properties": { "properties": {
"title": { "titleEn": {
"type": "string", "type": "string",
"maxLength": 160, "maxLength": 160,
"minLength": 0, "minLength": 0,
"nullable": false "nullable": false
}, },
"summary": { "descriptionEn": {
"type": "string", "type": "string",
"maxLength": 512, "maxLength": 4000,
"minLength": 0, "minLength": 0,
"nullable": false "nullable": false
}, },
"body": { "titleFr": {
"type": "string", "type": "string",
"maxLength": 8000, "maxLength": 160,
"minLength": 0,
"nullable": true
},
"category": {
"type": "string",
"maxLength": 32,
"minLength": 0, "minLength": 0,
"nullable": false "nullable": false
}, },
"importance": { "descriptionFr": {
"type": "string", "type": "string",
"maxLength": 32, "maxLength": 4000,
"minLength": 0, "minLength": 0,
"nullable": false "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
} }
} }
}, },
@@ -5106,30 +5030,6 @@
} }
} }
}, },
"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": { "SocializeApiModulesReleaseCommunicationsContractsReleaseCommitDto": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
@@ -5191,107 +5091,27 @@
} }
} }
}, },
"SocializeApiModulesReleaseCommunicationsHandlersImportDeveloperReleaseCommitsRequest": { "SocializeApiModulesReleaseCommunicationsContractsReleaseCommitRefreshResultDto": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"sinceSha": { "createdCount": {
"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", "type": "integer",
"format": "int32" "format": "int32"
}, },
"sentAt": { "updatedCount": {
"type": "string", "type": "integer",
"format": "date-time" "format": "int32"
}, },
"testMode": { "skippedCount": {
"type": "boolean" "type": "integer",
} "format": "int32"
}
},
"SocializeApiModulesReleaseCommunicationsHandlersSendDeveloperReleaseUpdateEmailRequest": {
"type": "object",
"additionalProperties": false,
"properties": {
"testMode": {
"type": "boolean"
}, },
"confirmResend": { "commits": {
"type": "boolean" "type": "array",
"items": {
"$ref": "#/components/schemas/SocializeApiModulesReleaseCommunicationsContractsReleaseCommitDto"
}
} }
} }
}, },
@@ -5305,70 +5125,59 @@
} }
} }
}, },
"SocializeApiModulesReleaseCommunicationsContractsReleaseCommitBulkLinkResultDto": {
"type": "object",
"additionalProperties": false,
"properties": {
"linkedCount": {
"type": "integer",
"format": "int32"
}
}
},
"SocializeApiModulesReleaseCommunicationsHandlersLinkFirstReleaseCommitsRequest": {
"type": "object",
"additionalProperties": false,
"properties": {
"releaseUpdateId": {
"type": "string",
"format": "guid"
}
}
},
"SocializeApiModulesReleaseCommunicationsHandlersUpdateDeveloperReleaseUpdateRequest": { "SocializeApiModulesReleaseCommunicationsHandlersUpdateDeveloperReleaseUpdateRequest": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": [ "required": [
"title", "titleEn",
"summary", "descriptionEn",
"category", "titleFr",
"importance", "descriptionFr"
"audience"
], ],
"properties": { "properties": {
"title": { "titleEn": {
"type": "string", "type": "string",
"maxLength": 160, "maxLength": 160,
"minLength": 0, "minLength": 0,
"nullable": false "nullable": false
}, },
"summary": { "descriptionEn": {
"type": "string", "type": "string",
"maxLength": 512, "maxLength": 4000,
"minLength": 0, "minLength": 0,
"nullable": false "nullable": false
}, },
"body": { "titleFr": {
"type": "string", "type": "string",
"maxLength": 8000, "maxLength": 160,
"minLength": 0,
"nullable": true
},
"category": {
"type": "string",
"maxLength": 32,
"minLength": 0, "minLength": 0,
"nullable": false "nullable": false
}, },
"importance": { "descriptionFr": {
"type": "string", "type": "string",
"maxLength": 32, "maxLength": 4000,
"minLength": 0, "minLength": 0,
"nullable": false "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
} }
} }
}, },