feat: add google drive dam foundation

This commit is contained in:
2026-05-08 11:36:30 -04:00
parent 2eb54b9228
commit 0fbb30bb4f
28 changed files with 3622 additions and 26 deletions

View File

@@ -15,6 +15,7 @@ using Socialize.Api.Modules.Campaigns.Data;
using Socialize.Api.Modules.Organizations.Data;
using Socialize.Api.Modules.Organizations.Services;
using Socialize.Api.Modules.Workspaces.Data;
using Socialize.Api.Modules.Workspaces.Services;
using Microsoft.AspNetCore.Identity;
namespace Socialize.Api.Infrastructure.TestData;
@@ -261,6 +262,10 @@ internal static class TestDataSeedExtensions
}
organization.Name = "Northstar Agency";
organization.IsGoogleDriveDamEnabled = true;
organization.GoogleDriveRootFolderId = "dev-socialize-dam-root";
organization.GoogleDriveRootFolderName = "Socialize DAM";
organization.GoogleDriveRootFolderUrl = "https://drive.google.com/drive/folders/dev-socialize-dam-root";
organization.MembershipTierId = OrganizationMembershipTierSeed.AgencyId;
organization.OwnerUserId = managerUserId;
@@ -465,6 +470,7 @@ internal static class TestDataSeedExtensions
asset.DisplayName = "Spring launch cut";
asset.GoogleDriveFileId = "dev-socialize-demo";
asset.GoogleDriveLink = "https://drive.google.com/file/d/dev-socialize-demo/view";
asset.GoogleDriveWorkspaceFolderPath = "Socialize DAM/luma-coffee";
asset.PreviewUrl = "https://drive.google.com/thumbnail?id=dev-socialize-demo";
asset.CurrentRevisionNumber = 2;
await dbContext.SaveChangesAsync(cancellationToken);
@@ -587,6 +593,7 @@ internal static class TestDataSeedExtensions
{
Id = id,
Name = string.Empty,
Slug = string.Empty,
TimeZone = string.Empty,
CreatedAt = DateTimeOffset.UtcNow,
};
@@ -594,6 +601,12 @@ internal static class TestDataSeedExtensions
}
workspace.Name = name;
workspace.Slug = await WorkspaceSlugGenerator.CreateUniqueAsync(
dbContext,
organizationId,
string.IsNullOrWhiteSpace(workspace.Slug) ? name : workspace.Slug,
workspace.Id,
cancellationToken);
workspace.OrganizationId = organizationId;
workspace.OwnerUserId = ownerUserId;
workspace.TimeZone = timeZone;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,139 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Socialize.Api.Migrations
{
/// <inheritdoc />
internal partial class AddGoogleDriveDamFoundation : Migration
{
private static readonly string[] WorkspaceOrganizationSlugIndexColumns =
[
"OrganizationId",
"Slug",
];
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
ArgumentNullException.ThrowIfNull(migrationBuilder);
migrationBuilder.AddColumn<string>(
name: "Slug",
table: "Workspaces",
type: "character varying(96)",
maxLength: 96,
nullable: false,
defaultValue: "");
migrationBuilder.Sql(
"""
WITH normalized AS (
SELECT
"Id",
"OrganizationId",
COALESCE(
NULLIF(
trim(both '-' from lower(regexp_replace(trim("Name"), '[^a-zA-Z0-9]+', '-', 'g'))),
''
),
'workspace'
) AS "BaseSlug"
FROM "Workspaces"
),
numbered AS (
SELECT
"Id",
"BaseSlug",
row_number() OVER (PARTITION BY "OrganizationId", "BaseSlug" ORDER BY "CreatedAt", "Id") AS "SlugIndex"
FROM normalized
)
UPDATE "Workspaces"
SET "Slug" = left(
CASE
WHEN numbered."SlugIndex" = 1 THEN numbered."BaseSlug"
ELSE numbered."BaseSlug" || '-' || numbered."SlugIndex"
END,
96
)
FROM numbered
WHERE "Workspaces"."Id" = numbered."Id";
""");
migrationBuilder.AddColumn<string>(
name: "GoogleDriveRootFolderId",
table: "Organizations",
type: "character varying(256)",
maxLength: 256,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "GoogleDriveRootFolderName",
table: "Organizations",
type: "character varying(256)",
maxLength: 256,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "GoogleDriveRootFolderUrl",
table: "Organizations",
type: "character varying(2048)",
maxLength: 2048,
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "IsGoogleDriveDamEnabled",
table: "Organizations",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<string>(
name: "GoogleDriveWorkspaceFolderPath",
table: "Assets",
type: "character varying(512)",
maxLength: 512,
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Workspaces_OrganizationId_Slug",
table: "Workspaces",
columns: WorkspaceOrganizationSlugIndexColumns,
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
ArgumentNullException.ThrowIfNull(migrationBuilder);
migrationBuilder.DropIndex(
name: "IX_Workspaces_OrganizationId_Slug",
table: "Workspaces");
migrationBuilder.DropColumn(
name: "Slug",
table: "Workspaces");
migrationBuilder.DropColumn(
name: "GoogleDriveRootFolderId",
table: "Organizations");
migrationBuilder.DropColumn(
name: "GoogleDriveRootFolderName",
table: "Organizations");
migrationBuilder.DropColumn(
name: "GoogleDriveRootFolderUrl",
table: "Organizations");
migrationBuilder.DropColumn(
name: "IsGoogleDriveDamEnabled",
table: "Organizations");
migrationBuilder.DropColumn(
name: "GoogleDriveWorkspaceFolderPath",
table: "Assets");
}
}
}

View File

@@ -374,6 +374,10 @@ namespace Socialize.Api.Migrations
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("GoogleDriveWorkspaceFolderPath")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("PreviewUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
@@ -1658,6 +1662,23 @@ namespace Socialize.Api.Migrations
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("GoogleDriveRootFolderId")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("GoogleDriveRootFolderName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("GoogleDriveRootFolderUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<bool>("IsGoogleDriveDamEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<string>("LogoUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
@@ -2167,6 +2188,11 @@ namespace Socialize.Api.Migrations
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(96)
.HasColumnType("character varying(96)");
b.Property<string>("TimeZone")
.IsRequired()
.HasMaxLength(128)
@@ -2178,6 +2204,9 @@ namespace Socialize.Api.Migrations
b.HasIndex("OwnerUserId");
b.HasIndex("OrganizationId", "Slug")
.IsUnique();
b.ToTable("Workspaces", (string)null);
});

View File

@@ -10,6 +10,7 @@ internal class Asset
public required string DisplayName { get; set; }
public string? GoogleDriveFileId { get; set; }
public string? GoogleDriveLink { get; set; }
public string? GoogleDriveWorkspaceFolderPath { get; set; }
public string? PreviewUrl { get; set; }
public int CurrentRevisionNumber { get; set; }
public DateTimeOffset CreatedAt { get; init; }

View File

@@ -17,6 +17,7 @@ internal static class AssetModelConfiguration
asset.Property(x => x.DisplayName).HasMaxLength(256).IsRequired();
asset.Property(x => x.GoogleDriveFileId).HasMaxLength(256);
asset.Property(x => x.GoogleDriveLink).HasMaxLength(2048);
asset.Property(x => x.GoogleDriveWorkspaceFolderPath).HasMaxLength(512);
asset.Property(x => x.PreviewUrl).HasMaxLength(2048);
asset.Property(x => x.CreatedAt)
.ValueGeneratedOnAdd()

View File

@@ -6,6 +6,8 @@ using Socialize.Api.Modules.Assets.Data;
using Socialize.Api.Modules.ContentItems.Contracts;
using Socialize.Api.Modules.ContentItems.Data;
using Socialize.Api.Modules.Notifications.Contracts;
using Socialize.Api.Modules.Organizations.Data;
using Socialize.Api.Modules.Workspaces.Data;
using System.Text.Json;
namespace Socialize.Api.Modules.Assets.Handlers;
@@ -67,6 +69,26 @@ internal class CreateGoogleDriveAssetHandler(
return;
}
Workspace? workspace = await dbContext.Workspaces
.SingleOrDefaultAsync(candidate => candidate.Id == contentItem.WorkspaceId, ct);
if (workspace is null)
{
await SendNotFoundAsync(ct);
return;
}
Organization? organization = await dbContext.Organizations
.SingleOrDefaultAsync(candidate => candidate.Id == workspace.OrganizationId, ct);
if (organization is null)
{
await SendNotFoundAsync(ct);
return;
}
string? workspaceFolderPath = organization.IsGoogleDriveDamEnabled
? $"{organization.GoogleDriveRootFolderName}/{workspace.Slug}"
: null;
Asset asset = new()
{
Id = Guid.NewGuid(),
@@ -77,6 +99,7 @@ internal class CreateGoogleDriveAssetHandler(
DisplayName = request.DisplayName.Trim(),
GoogleDriveFileId = request.GoogleDriveFileId.Trim(),
GoogleDriveLink = request.GoogleDriveLink.Trim(),
GoogleDriveWorkspaceFolderPath = workspaceFolderPath,
PreviewUrl = string.IsNullOrWhiteSpace(request.PreviewUrl) ? null : request.PreviewUrl.Trim(),
CurrentRevisionNumber = 1,
CreatedAt = DateTimeOffset.UtcNow,
@@ -111,6 +134,7 @@ internal class CreateGoogleDriveAssetHandler(
assetType = asset.AssetType,
sourceType = asset.SourceType,
googleDriveFileId = asset.GoogleDriveFileId,
googleDriveWorkspaceFolderPath = asset.GoogleDriveWorkspaceFolderPath,
currentRevisionNumber = asset.CurrentRevisionNumber,
})),
ct);
@@ -137,6 +161,7 @@ internal class CreateGoogleDriveAssetHandler(
asset.DisplayName,
asset.GoogleDriveFileId,
asset.GoogleDriveLink,
asset.GoogleDriveWorkspaceFolderPath,
asset.PreviewUrl,
asset.CurrentRevisionNumber,
asset.CreatedAt,

View File

@@ -26,6 +26,7 @@ internal record AssetDto(
string DisplayName,
string? GoogleDriveFileId,
string? GoogleDriveLink,
string? GoogleDriveWorkspaceFolderPath,
string? PreviewUrl,
int CurrentRevisionNumber,
DateTimeOffset CreatedAt,
@@ -70,6 +71,7 @@ internal class GetAssetsHandler(
asset.DisplayName,
asset.GoogleDriveFileId,
asset.GoogleDriveLink,
asset.GoogleDriveWorkspaceFolderPath,
asset.PreviewUrl,
asset.CurrentRevisionNumber,
asset.CreatedAt,

View File

@@ -0,0 +1,123 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Organizations.Data;
using Socialize.Api.Modules.Workspaces.Data;
namespace Socialize.Api.Modules.Assets.Handlers;
internal record WorkspaceDamBackingStoreDto(
string Type,
bool IsConfigured,
string? RootFolderId,
string? RootFolderName,
string? RootFolderUrl);
internal record WorkspaceDamFolderDto(
string Name,
string Path);
internal record WorkspaceDamDto(
Guid WorkspaceId,
Guid OrganizationId,
string WorkspaceName,
string WorkspaceSlug,
WorkspaceDamBackingStoreDto BackingStore,
WorkspaceDamFolderDto? Folder,
IReadOnlyCollection<AssetDto> Assets);
internal class GetWorkspaceDamHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService)
: EndpointWithoutRequest<WorkspaceDamDto>
{
public override void Configure()
{
Get("/api/workspaces/{workspaceId:guid}/dam");
Options(o => o.WithTags("Assets"));
}
public override async Task HandleAsync(CancellationToken ct)
{
Guid workspaceId = Route<Guid>("workspaceId");
Workspace? workspace = await dbContext.Workspaces
.SingleOrDefaultAsync(candidate => candidate.Id == workspaceId, ct);
if (workspace is null)
{
await SendNotFoundAsync(ct);
return;
}
IReadOnlyCollection<Guid> accessibleWorkspaceIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
if (!accessibleWorkspaceIds.Contains(workspace.Id))
{
await SendForbiddenAsync(ct);
return;
}
Organization? organization = await dbContext.Organizations
.SingleOrDefaultAsync(candidate => candidate.Id == workspace.OrganizationId, ct);
if (organization is null)
{
await SendNotFoundAsync(ct);
return;
}
WorkspaceDamBackingStoreDto backingStore = new(
organization.IsGoogleDriveDamEnabled ? "GoogleDrive" : "Unconfigured",
organization.IsGoogleDriveDamEnabled,
organization.GoogleDriveRootFolderId,
organization.GoogleDriveRootFolderName,
organization.GoogleDriveRootFolderUrl);
WorkspaceDamFolderDto? folder = organization.IsGoogleDriveDamEnabled
? new WorkspaceDamFolderDto(
workspace.Slug,
$"{organization.GoogleDriveRootFolderName}/{workspace.Slug}")
: null;
List<AssetDto> assets = await dbContext.Assets
.Where(asset => asset.WorkspaceId == workspace.Id)
.OrderBy(asset => asset.DisplayName)
.Select(asset => new AssetDto(
asset.Id,
asset.WorkspaceId,
asset.ContentItemId,
asset.AssetType,
asset.SourceType,
asset.DisplayName,
asset.GoogleDriveFileId,
asset.GoogleDriveLink,
asset.GoogleDriveWorkspaceFolderPath,
asset.PreviewUrl,
asset.CurrentRevisionNumber,
asset.CreatedAt,
dbContext.AssetRevisions
.Where(revision => revision.AssetId == asset.Id)
.OrderByDescending(revision => revision.RevisionNumber)
.Select(revision => new AssetRevisionDto(
revision.Id,
revision.AssetId,
revision.RevisionNumber,
revision.SourceReference,
revision.PreviewUrl,
revision.Notes,
revision.CreatedByUserId,
revision.CreatedAt))
.ToList()))
.ToListAsync(ct);
await SendOkAsync(
new WorkspaceDamDto(
workspace.Id,
workspace.OrganizationId,
workspace.Name,
workspace.Slug,
backingStore,
folder,
assets),
ct);
}
}

View File

@@ -5,6 +5,10 @@ internal class Organization
public Guid Id { get; init; }
public required string Name { get; set; }
public string? LogoUrl { get; set; }
public bool IsGoogleDriveDamEnabled { get; set; }
public string? GoogleDriveRootFolderId { get; set; }
public string? GoogleDriveRootFolderName { get; set; }
public string? GoogleDriveRootFolderUrl { get; set; }
public Guid MembershipTierId { get; set; } = OrganizationMembershipTierSeed.FreeId;
public Guid OwnerUserId { get; set; }
public DateTimeOffset CreatedAt { get; init; }

View File

@@ -12,6 +12,10 @@ internal static class OrganizationModelConfiguration
organization.HasKey(x => x.Id);
organization.Property(x => x.Name).HasMaxLength(256).IsRequired();
organization.Property(x => x.LogoUrl).HasMaxLength(2048);
organization.Property(x => x.IsGoogleDriveDamEnabled).HasDefaultValue(false);
organization.Property(x => x.GoogleDriveRootFolderId).HasMaxLength(256);
organization.Property(x => x.GoogleDriveRootFolderName).HasMaxLength(256);
organization.Property(x => x.GoogleDriveRootFolderUrl).HasMaxLength(2048);
organization.Property(x => x.MembershipTierId)
.HasDefaultValue(OrganizationMembershipTierSeed.FreeId);
organization.Property(x => x.CreatedAt)

View File

@@ -16,6 +16,7 @@ internal record OrganizationDto(
Guid Id,
string Name,
string? LogoUrl,
OrganizationGoogleDriveDamConfigurationDto GoogleDriveDam,
OrganizationMembershipTierDto? MembershipTier,
Guid OwnerUserId,
IReadOnlyCollection<string> CurrentUserPermissions,
@@ -38,6 +39,7 @@ internal record OrganizationDto(
organization.Id,
organization.Name,
organization.LogoUrl,
OrganizationGoogleDriveDamConfigurationDto.FromOrganization(organization),
membershipTier,
organization.OwnerUserId,
currentUserPermissions,
@@ -49,6 +51,22 @@ internal record OrganizationDto(
}
}
internal record OrganizationGoogleDriveDamConfigurationDto(
bool IsEnabled,
string? RootFolderId,
string? RootFolderName,
string? RootFolderUrl)
{
public static OrganizationGoogleDriveDamConfigurationDto FromOrganization(Organization organization)
{
return new OrganizationGoogleDriveDamConfigurationDto(
organization.IsGoogleDriveDamEnabled,
organization.GoogleDriveRootFolderId,
organization.GoogleDriveRootFolderName,
organization.GoogleDriveRootFolderUrl);
}
}
internal record OrganizationMembershipTierDto(
Guid Id,
string Key,

View File

@@ -0,0 +1,87 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Modules.Organizations.Data;
using Socialize.Api.Modules.Organizations.Services;
namespace Socialize.Api.Modules.Organizations.Handlers;
internal record UpdateGoogleDriveDamConfigurationRequest(
bool IsEnabled,
string? RootFolderId,
string? RootFolderName,
string? RootFolderUrl);
internal class UpdateGoogleDriveDamConfigurationRequestValidator
: Validator<UpdateGoogleDriveDamConfigurationRequest>
{
public UpdateGoogleDriveDamConfigurationRequestValidator()
{
When(x => x.IsEnabled, () =>
{
RuleFor(x => x.RootFolderId).NotEmpty().MaximumLength(256);
RuleFor(x => x.RootFolderName).NotEmpty().MaximumLength(256);
RuleFor(x => x.RootFolderUrl).NotEmpty().MaximumLength(2048);
});
RuleFor(x => x.RootFolderId).MaximumLength(256);
RuleFor(x => x.RootFolderName).MaximumLength(256);
RuleFor(x => x.RootFolderUrl).MaximumLength(2048);
}
}
internal class UpdateGoogleDriveDamConfigurationHandler(
AppDbContext dbContext,
OrganizationAccessService organizationAccessService)
: Endpoint<UpdateGoogleDriveDamConfigurationRequest, OrganizationGoogleDriveDamConfigurationDto>
{
public override void Configure()
{
Put("/api/organizations/{organizationId:guid}/google-drive-dam");
Options(o => o.WithTags("Organizations"));
}
public override async Task HandleAsync(UpdateGoogleDriveDamConfigurationRequest request, CancellationToken ct)
{
Guid organizationId = Route<Guid>("organizationId");
Organization? organization = await dbContext.Organizations
.SingleOrDefaultAsync(candidate => candidate.Id == organizationId, ct);
if (organization is null)
{
await SendNotFoundAsync(ct);
return;
}
if (!await organizationAccessService.HasOrganizationPermissionAsync(
User,
organizationId,
OrganizationPermissions.ManageConnectors,
ct))
{
await SendForbiddenAsync(ct);
return;
}
organization.IsGoogleDriveDamEnabled = request.IsEnabled;
organization.GoogleDriveRootFolderId = NormalizeOptional(request.RootFolderId);
organization.GoogleDriveRootFolderName = NormalizeOptional(request.RootFolderName);
organization.GoogleDriveRootFolderUrl = NormalizeOptional(request.RootFolderUrl);
if (!organization.IsGoogleDriveDamEnabled)
{
organization.GoogleDriveRootFolderId = null;
organization.GoogleDriveRootFolderName = null;
organization.GoogleDriveRootFolderUrl = null;
}
await dbContext.SaveChangesAsync(ct);
await SendOkAsync(OrganizationGoogleDriveDamConfigurationDto.FromOrganization(organization), ct);
}
private static string? NormalizeOptional(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
}

View File

@@ -4,6 +4,7 @@ internal class Workspace
{
public Guid Id { get; init; }
public required string Name { get; set; }
public required string Slug { get; set; }
public string? LogoUrl { get; set; }
public Guid OrganizationId { get; set; }
public Guid OwnerUserId { get; set; }

View File

@@ -12,6 +12,7 @@ internal static class WorkspaceModelConfiguration
workspace.ToTable("Workspaces");
workspace.HasKey(x => x.Id);
workspace.Property(x => x.Name).HasMaxLength(256).IsRequired();
workspace.Property(x => x.Slug).HasMaxLength(96).IsRequired();
workspace.Property(x => x.LogoUrl).HasMaxLength(2048);
workspace.Property(x => x.TimeZone).HasMaxLength(128).IsRequired();
workspace.Property(x => x.ApprovalMode).HasMaxLength(32).IsRequired().HasDefaultValue("Required");
@@ -22,6 +23,7 @@ internal static class WorkspaceModelConfiguration
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
workspace.HasIndex(x => x.OrganizationId);
workspace.HasIndex(x => new { x.OrganizationId, x.Slug }).IsUnique();
workspace.HasIndex(x => x.OwnerUserId);
workspace.HasOne<Organization>()
.WithMany()

View File

@@ -3,12 +3,14 @@ using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Workspaces.Data;
using Socialize.Api.Modules.Workspaces.Services;
namespace Socialize.Api.Modules.Workspaces.Handlers;
internal record CreateWorkspaceRequest(
Guid OrganizationId,
string Name,
string? Slug,
string TimeZone);
internal class CreateWorkspaceRequestValidator
@@ -18,6 +20,7 @@ internal class CreateWorkspaceRequestValidator
{
RuleFor(x => x.OrganizationId).NotEmpty();
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
RuleFor(x => x.Slug).MaximumLength(96);
RuleFor(x => x.TimeZone).NotEmpty().MaximumLength(128);
}
}
@@ -51,6 +54,12 @@ internal class CreateWorkspaceHandler(
}
string normalizedName = request.Name.Trim();
string normalizedSlug = await WorkspaceSlugGenerator.CreateUniqueAsync(
dbContext,
request.OrganizationId,
string.IsNullOrWhiteSpace(request.Slug) ? normalizedName : request.Slug,
null,
ct);
string normalizedTimeZone = request.TimeZone.Trim();
Workspace workspace = new()
@@ -58,6 +67,7 @@ internal class CreateWorkspaceHandler(
Id = Guid.NewGuid(),
OrganizationId = request.OrganizationId,
Name = normalizedName,
Slug = normalizedSlug,
OwnerUserId = User.GetUserId(),
TimeZone = normalizedTimeZone,
CreatedAt = DateTimeOffset.UtcNow,

View File

@@ -21,6 +21,7 @@ internal record WorkspaceDto(
Guid Id,
Guid OrganizationId,
string Name,
string Slug,
string? LogoUrl,
string TimeZone,
string ApprovalMode,
@@ -38,6 +39,7 @@ internal record WorkspaceDto(
workspace.Id,
workspace.OrganizationId,
workspace.Name,
workspace.Slug,
workspace.LogoUrl,
workspace.TimeZone,
workspace.ApprovalMode,

View File

@@ -5,6 +5,7 @@ using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Approvals.Data;
using Socialize.Api.Modules.Approvals.Services;
using Socialize.Api.Modules.Workspaces.Data;
using Socialize.Api.Modules.Workspaces.Services;
namespace Socialize.Api.Modules.Workspaces.Handlers;
@@ -17,6 +18,7 @@ internal record UpdateApprovalStepConfigurationRequest(
internal record UpdateWorkspaceRequest(
string Name,
string? Slug,
string TimeZone,
string? ApprovalMode,
bool? SchedulePostsAutomaticallyOnApproval,
@@ -32,6 +34,7 @@ internal class UpdateWorkspaceRequestValidator
public UpdateWorkspaceRequestValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
RuleFor(x => x.Slug).MaximumLength(96);
RuleFor(x => x.TimeZone).NotEmpty().MaximumLength(128);
RuleFor(x => x.ApprovalMode)
.Must(mode => string.IsNullOrWhiteSpace(mode) || AllowedApprovalModes.Contains(mode.Trim()))
@@ -106,6 +109,12 @@ internal class UpdateWorkspaceHandler(
}
workspace.Name = request.Name.Trim();
workspace.Slug = await WorkspaceSlugGenerator.CreateUniqueAsync(
dbContext,
workspace.OrganizationId,
string.IsNullOrWhiteSpace(request.Slug) ? workspace.Name : request.Slug,
workspace.Id,
ct);
workspace.TimeZone = request.TimeZone.Trim();
workspace.ApprovalMode = nextApprovalMode;
workspace.SchedulePostsAutomaticallyOnApproval = request.SchedulePostsAutomaticallyOnApproval ?? workspace.SchedulePostsAutomaticallyOnApproval;

View File

@@ -0,0 +1,64 @@
using System.Text;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
namespace Socialize.Api.Modules.Workspaces.Services;
internal static class WorkspaceSlugGenerator
{
public static string Normalize(string value)
{
#pragma warning disable CA1308 // Workspace slugs are intentionally lowercase external folder names.
string trimmed = value.Trim().ToLowerInvariant();
#pragma warning restore CA1308
var builder = new StringBuilder(trimmed.Length);
bool previousWasSeparator = false;
foreach (char character in trimmed)
{
if (char.IsLetterOrDigit(character))
{
builder.Append(character);
previousWasSeparator = false;
continue;
}
if (previousWasSeparator)
{
continue;
}
builder.Append('-');
previousWasSeparator = true;
}
string slug = builder.ToString().Trim('-');
return string.IsNullOrWhiteSpace(slug) ? "workspace" : slug[..Math.Min(slug.Length, 96)];
}
public static async Task<string> CreateUniqueAsync(
AppDbContext dbContext,
Guid organizationId,
string source,
Guid? excludingWorkspaceId,
CancellationToken ct)
{
string baseSlug = Normalize(source);
string candidate = baseSlug;
int suffix = 2;
while (await dbContext.Workspaces.AnyAsync(
workspace => workspace.OrganizationId == organizationId &&
workspace.Slug == candidate &&
(!excludingWorkspaceId.HasValue || workspace.Id != excludingWorkspaceId.Value),
ct))
{
string suffixText = $"-{suffix}";
int maxBaseLength = 96 - suffixText.Length;
candidate = $"{baseSlug[..Math.Min(baseSlug.Length, maxBaseLength)]}{suffixText}";
suffix++;
}
return candidate;
}
}