1 Commits

Author SHA1 Message Date
0fbb30bb4f feat: add google drive dam foundation 2026-05-08 11:36:30 -04:00
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;
}
}

View File

@@ -0,0 +1,56 @@
# Digital Asset Management
## Status
Draft
## Goal
Provide a workspace media library backed by the organization's configured Google Drive.
The DAM is not a standalone storage system in v1. When an organization has Google Drive configured, Google Drive is the backing store for media files and Socialize stores metadata, workflow relationships, revisions, and audit history.
## Backing Store
Google Drive configuration belongs to the organization.
An organization Google Drive configuration includes:
- enabled state
- root folder id
- root folder name
- root folder URL
Workspace media is organized inside the organization Drive root by workspace slug:
```txt
<organization-drive-root>/<workspace-slug>/
```
Each workspace must have a stable slug. Slugs are unique within the owning organization and should not change casually because they map to an external DAM folder.
## Workspace DAM
The workspace DAM view should expose:
- whether Google Drive is configured for the owning organization
- the workspace slug
- the resolved workspace DAM folder name and path
- linked media assets for the workspace
- asset revisions and source references
## Business Rules
- Organization connector settings are managed by users with `ManageConnectors`.
- Workspace slugs are required and unique within an organization.
- Asset metadata remains workspace-scoped.
- Content item assets can point at Google Drive files in the workspace DAM folder.
- Socialize must not claim to upload or sync files until the Google Drive API integration exists.
## Out Of Scope For First Slice
- Google OAuth consent and refresh-token storage.
- Creating folders in Google Drive through the Drive API.
- Uploading files to Google Drive.
- Background Drive synchronization.
- Moving existing files between Drive folders.

View File

@@ -0,0 +1,45 @@
# Task: Add Google Drive backed DAM foundation
## Feature
`docs/FEATURES/digital-asset-management.md`
## Goal
Make the DAM model aware of organization-level Google Drive backing storage and workspace slug folders.
## Scope
- Add organization Google Drive configuration metadata.
- Add a required workspace `Slug`, unique within the owning organization.
- Generate a slug from workspace name during workspace creation.
- Allow workspace managers to update a workspace slug.
- Add DAM metadata to workspace asset responses.
- Add a workspace DAM endpoint that returns backing-store configuration, workspace folder information, and workspace assets.
- Keep actual Google Drive API folder creation, uploads, and sync out of scope.
## Likely Files
- `backend/src/Socialize.Api/Modules/Organizations/`
- `backend/src/Socialize.Api/Modules/Workspaces/`
- `backend/src/Socialize.Api/Modules/Assets/`
- `backend/src/Socialize.Api/Data/AppDbContext.cs`
- `frontend/src/features/content/views/MediaLibraryView.vue`
- `frontend/src/features/content/stores/`
## Validation
```bash
dotnet build backend/Socialize.slnx
dotnet test backend/Socialize.slnx
cd frontend && npm run build
```
## Acceptance Criteria
- [x] Organization responses include Google Drive DAM configuration metadata.
- [x] Organization connector managers can save Google Drive DAM configuration.
- [x] Workspace responses include a stable slug.
- [x] New workspaces receive a unique slug based on the workspace name.
- [x] Workspace DAM data resolves to `<drive-root>/<workspace-slug>`.
- [x] Existing manually linked Google Drive content assets remain supported.

View File

@@ -0,0 +1,41 @@
import { ref } from 'vue';
import { defineStore } from 'pinia';
import { useClient } from '@/plugins/api.js';
export const useMediaLibraryStore = defineStore('media-library', () => {
const client = useClient();
const dam = ref(null);
const isLoading = ref(false);
const error = ref(null);
async function fetchWorkspaceDam(workspaceId) {
if (!workspaceId) {
dam.value = null;
return null;
}
isLoading.value = true;
error.value = null;
try {
const response = await client.get(`/api/workspaces/${workspaceId}/dam`);
dam.value = response.data ?? null;
return dam.value;
} catch (fetchError) {
console.error('Failed to load workspace DAM:', fetchError);
dam.value = null;
error.value = 'Failed to load the media library.';
return null;
} finally {
isLoading.value = false;
}
}
return {
dam,
isLoading,
error,
fetchWorkspaceDam,
};
});

View File

@@ -1,25 +1,35 @@
<script setup>
import { computed, onMounted, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMediaLibraryStore } from '@/features/content/stores/mediaLibraryStore.js';
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
import {
mdiCheckCircleOutline,
mdiCloudSyncOutline,
mdiFolderGoogleDrive,
mdiImageMultipleOutline,
mdiVideoOutline,
} from '@mdi/js';
const { t } = useI18n();
const mediaLibraryStore = useMediaLibraryStore();
const workspaceStore = useWorkspaceStore();
const mediaTypes = [
{ label: t('mediaLibrary.mediaTypes.images'), icon: mdiImageMultipleOutline },
{ label: t('mediaLibrary.mediaTypes.videos'), icon: mdiVideoOutline },
];
const activeWorkspaceId = computed(() => workspaceStore.activeWorkspaceId);
const dam = computed(() => mediaLibraryStore.dam);
const assets = computed(() => dam.value?.assets ?? []);
const folderPath = computed(() => dam.value?.folder?.path ?? t('mediaLibrary.notConfigured'));
const workflowSteps = [
t('mediaLibrary.workflow.connectDrive'),
t('mediaLibrary.workflow.syncAssets'),
t('mediaLibrary.workflow.organizeLibrary'),
];
async function loadDam() {
await mediaLibraryStore.fetchWorkspaceDam(activeWorkspaceId.value);
}
onMounted(loadDam);
watch(activeWorkspaceId, loadDam);
</script>
<template>
@@ -35,26 +45,40 @@
<div class="hero-card-icon">
<v-icon :icon="mdiFolderGoogleDrive" />
</div>
<strong>{{ t('mediaLibrary.syncCard.title') }}</strong>
<span>{{ t('mediaLibrary.syncCard.description') }}</span>
<strong>{{ dam?.backingStore?.isConfigured ? dam.backingStore.rootFolderName : t('mediaLibrary.syncCard.title') }}</strong>
<span>{{ dam?.backingStore?.isConfigured ? folderPath : t('mediaLibrary.syncCard.description') }}</span>
</div>
</div>
<div
v-if="mediaLibraryStore.isLoading"
class="page-message"
>
{{ t('mediaLibrary.loading') }}
</div>
<div
v-else-if="mediaLibraryStore.error"
class="page-message error"
>
{{ mediaLibraryStore.error }}
</div>
<div class="content-grid">
<article class="panel">
<div class="panel-header">
<strong>{{ t('mediaLibrary.mediaTypesTitle') }}</strong>
<span>{{ t('mediaLibrary.mediaTypesDescription') }}</span>
<strong>{{ t('mediaLibrary.damRootTitle') }}</strong>
<span>{{ dam?.backingStore?.isConfigured ? dam.backingStore.rootFolderUrl : t('mediaLibrary.notConfiguredDescription') }}</span>
</div>
<div class="media-type-list">
<div
v-for="type in mediaTypes"
:key="type.label"
class="media-type-item"
>
<v-icon :icon="type.icon" />
<span>{{ type.label }}</span>
<div class="media-type-item">
<v-icon :icon="mdiFolderGoogleDrive" />
<span>{{ folderPath }}</span>
</div>
<div class="media-type-item">
<v-icon :icon="mdiCheckCircleOutline" />
<span>{{ t('mediaLibrary.workspaceSlug', { slug: dam?.workspaceSlug ?? '-' }) }}</span>
</div>
</div>
</article>
@@ -87,8 +111,40 @@
<v-icon :icon="mdiCloudSyncOutline" />
<span>{{ t('mediaLibrary.statusLabel') }}</span>
</div>
<strong>{{ t('mediaLibrary.pendingTitle') }}</strong>
<p>{{ t('mediaLibrary.pendingDescription') }}</p>
<strong>{{ dam?.backingStore?.isConfigured ? t('mediaLibrary.configuredTitle') : t('mediaLibrary.pendingTitle') }}</strong>
<p>{{ dam?.backingStore?.isConfigured ? t('mediaLibrary.configuredDescription') : t('mediaLibrary.pendingDescription') }}</p>
</div>
</article>
<article class="panel">
<div class="panel-header">
<strong>{{ t('mediaLibrary.assetsTitle') }}</strong>
<span>{{ t('mediaLibrary.assetsDescription') }}</span>
</div>
<div
v-if="!assets.length"
class="empty-state"
>
{{ t('mediaLibrary.emptyAssets') }}
</div>
<div
v-for="asset in assets"
:key="asset.id"
class="asset-row"
>
<div>
<strong>{{ asset.displayName }}</strong>
<span>{{ asset.assetType }} · {{ asset.googleDriveWorkspaceFolderPath || folderPath }}</span>
</div>
<a
v-if="asset.googleDriveLink"
:href="asset.googleDriveLink"
target="_blank"
rel="noreferrer"
>
{{ t('mediaLibrary.openDrive') }}
</a>
</div>
</article>
</section>
@@ -189,7 +245,8 @@
}
.media-type-list,
.workflow-list {
.workflow-list,
.asset-row-list {
@apply flex flex-col gap-3;
}
@@ -204,6 +261,23 @@
color: #0f766e;
}
.asset-row {
@apply flex items-center justify-between gap-4 rounded-[1.1rem] border px-4 py-3;
border-color: rgba(23, 32, 51, 0.08);
background: rgba(248, 250, 252, 0.9);
}
.asset-row div {
@apply flex min-w-0 flex-col gap-1;
}
.asset-row span,
.asset-row a,
.empty-state {
@apply text-sm;
color: #526178;
}
.status-panel {
background: linear-gradient(135deg, rgba(255, 247, 237, 0.95), rgba(255, 255, 255, 0.98));
}

View File

@@ -27,6 +27,7 @@ export const useOrganizationStore = defineStore('organization', () => {
const isLoadingMembershipTiers = ref(false);
const isSaving = ref(false);
const isUpdatingMembershipTier = ref(false);
const isSavingGoogleDriveDam = ref(false);
const isAddingMember = ref(false);
const isUploadingLogo = ref(false);
const error = ref(null);
@@ -260,6 +261,45 @@ export const useOrganizationStore = defineStore('organization', () => {
}
}
async function updateGoogleDriveDam(organizationId, payload) {
if (!authStore.isAuthenticated || !organizationId) {
throw new Error('You must be authenticated to update organization connectors.');
}
isSavingGoogleDriveDam.value = true;
error.value = null;
try {
const response = await client.put(`/api/organizations/${organizationId}/google-drive-dam`, payload);
const googleDriveDam = response.data;
const currentDetails = detailsById.value[organizationId];
if (currentDetails) {
detailsById.value = {
...detailsById.value,
[organizationId]: {
...currentDetails,
googleDriveDam,
},
};
}
organizations.value = organizations.value.map(organization =>
organization.id === organizationId
? { ...organization, googleDriveDam }
: organization
);
return googleDriveDam;
} catch (updateError) {
console.error('Failed to update Google Drive DAM configuration:', updateError);
error.value = 'Failed to update Google Drive DAM configuration.';
throw updateError;
} finally {
isSavingGoogleDriveDam.value = false;
}
}
async function addMember(organizationId, payload) {
if (!authStore.isAuthenticated || !organizationId) {
throw new Error('You must be authenticated to add an organization member.');
@@ -371,6 +411,7 @@ export const useOrganizationStore = defineStore('organization', () => {
isLoadingMembershipTiers,
isSaving,
isUpdatingMembershipTier,
isSavingGoogleDriveDam,
isAddingMember,
isUploadingLogo,
error,
@@ -383,6 +424,7 @@ export const useOrganizationStore = defineStore('organization', () => {
createOrganization,
updateOrganization,
updateMembershipTier,
updateGoogleDriveDam,
addMember,
uploadLogo,
};

View File

@@ -38,6 +38,12 @@
const membershipTierForm = reactive({
membershipTierId: null,
});
const googleDriveDamForm = reactive({
isEnabled: false,
rootFolderId: '',
rootFolderName: '',
rootFolderUrl: '',
});
const memberRoleOptions = ['Member', 'Admin', 'BillingManager', 'ConnectorManager'];
const organizationId = computed(() => route.params.organizationId);
@@ -195,6 +201,34 @@
}
}
async function submitGoogleDriveDam() {
settingsError.value = null;
settingsStatus.value = null;
if (
googleDriveDamForm.isEnabled &&
(!googleDriveDamForm.rootFolderId.trim() ||
!googleDriveDamForm.rootFolderName.trim() ||
!googleDriveDamForm.rootFolderUrl.trim())
) {
settingsError.value = t('organizationSettings.sections.connections.googleDrive.required');
return;
}
try {
await organizationStore.updateGoogleDriveDam(organizationId.value, {
isEnabled: googleDriveDamForm.isEnabled,
rootFolderId: googleDriveDamForm.rootFolderId.trim() || null,
rootFolderName: googleDriveDamForm.rootFolderName.trim() || null,
rootFolderUrl: googleDriveDamForm.rootFolderUrl.trim() || null,
});
settingsStatus.value = t('organizationSettings.sections.connections.googleDrive.saved');
} catch (error) {
console.error('Failed to save Google Drive DAM configuration:', error);
settingsError.value = t('organizationSettings.sections.connections.googleDrive.saveFailed');
}
}
function formatTierSummary(tier) {
const price = tier.isCustom || tier.monthlyPriceCents === null || tier.monthlyPriceCents === undefined
? t('organizationSettings.tiers.customPrice')
@@ -232,6 +266,10 @@
currentOrganization => {
profileForm.name = currentOrganization?.name ?? '';
membershipTierForm.membershipTierId = currentOrganization?.membershipTier?.id ?? null;
googleDriveDamForm.isEnabled = Boolean(currentOrganization?.googleDriveDam?.isEnabled);
googleDriveDamForm.rootFolderId = currentOrganization?.googleDriveDam?.rootFolderId ?? '';
googleDriveDamForm.rootFolderName = currentOrganization?.googleDriveDam?.rootFolderName ?? '';
googleDriveDamForm.rootFolderUrl = currentOrganization?.googleDriveDam?.rootFolderUrl ?? '';
},
{ immediate: true }
);
@@ -457,10 +495,55 @@
<div
v-else-if="activeSection.key === 'connections'"
class="placeholder-panel"
class="settings-form"
>
<strong>{{ t('organizationSettings.sections.connections.placeholderTitle') }}</strong>
<span>{{ t('organizationSettings.sections.connections.placeholderText') }}</span>
<div class="placeholder-panel">
<strong>{{ t('organizationSettings.sections.connections.googleDrive.title') }}</strong>
<span>{{ t('organizationSettings.sections.connections.googleDrive.description') }}</span>
</div>
<v-form
class="settings-form"
@submit.prevent="submitGoogleDriveDam"
>
<v-switch
v-model="googleDriveDamForm.isEnabled"
:label="t('organizationSettings.sections.connections.googleDrive.enabled')"
color="primary"
hide-details
/>
<v-text-field
v-model="googleDriveDamForm.rootFolderName"
:label="t('organizationSettings.sections.connections.googleDrive.rootFolderName')"
:disabled="!googleDriveDamForm.isEnabled || organizationStore.isSavingGoogleDriveDam"
maxlength="256"
variant="outlined"
hide-details
/>
<v-text-field
v-model="googleDriveDamForm.rootFolderId"
:label="t('organizationSettings.sections.connections.googleDrive.rootFolderId')"
:disabled="!googleDriveDamForm.isEnabled || organizationStore.isSavingGoogleDriveDam"
maxlength="256"
variant="outlined"
hide-details
/>
<v-text-field
v-model="googleDriveDamForm.rootFolderUrl"
:label="t('organizationSettings.sections.connections.googleDrive.rootFolderUrl')"
:disabled="!googleDriveDamForm.isEnabled || organizationStore.isSavingGoogleDriveDam"
maxlength="2048"
variant="outlined"
hide-details
/>
<v-btn
color="primary"
type="submit"
:loading="organizationStore.isSavingGoogleDriveDam"
>
{{ t('organizationSettings.sections.connections.googleDrive.save') }}
</v-btn>
</v-form>
</div>
<div

View File

@@ -20,6 +20,7 @@
const activeTab = ref('general');
const settingsForm = reactive({
name: '',
slug: '',
timeZone: '',
approvalMode: 'Required',
schedulePostsAutomaticallyOnApproval: false,
@@ -56,6 +57,7 @@
const workspaceApprovalSteps = normalizeApprovalSteps(workspace.approvalSteps ?? []);
return settingsForm.name.trim() !== workspace.name ||
settingsForm.slug.trim() !== workspace.slug ||
settingsForm.timeZone.trim() !== workspace.timeZone ||
settingsForm.approvalMode !== (workspace.approvalMode ?? 'Required') ||
settingsForm.schedulePostsAutomaticallyOnApproval !== Boolean(workspace.schedulePostsAutomaticallyOnApproval) ||
@@ -196,6 +198,7 @@
() => workspaceStore.activeWorkspace,
workspace => {
settingsForm.name = workspace?.name ?? '';
settingsForm.slug = workspace?.slug ?? '';
settingsForm.timeZone = workspace?.timeZone ?? '';
settingsForm.approvalMode = workspace?.approvalMode ?? 'Required';
settingsForm.schedulePostsAutomaticallyOnApproval = Boolean(workspace?.schedulePostsAutomaticallyOnApproval);
@@ -237,9 +240,10 @@
settingsStatus.value = null;
const name = settingsForm.name.trim();
const slug = settingsForm.slug.trim();
const timeZone = settingsForm.timeZone.trim();
if (!name || !timeZone) {
if (!name || !slug || !timeZone) {
settingsError.value = t('workspaceSettings.errors.required');
return;
}
@@ -254,6 +258,7 @@
try {
await workspaceStore.updateWorkspace(workspace.id, {
name,
slug,
timeZone,
approvalMode: settingsForm.approvalMode,
schedulePostsAutomaticallyOnApproval: settingsForm.schedulePostsAutomaticallyOnApproval,
@@ -504,6 +509,15 @@
hide-details
/>
<v-text-field
v-model="settingsForm.slug"
:label="t('workspaceSettings.fields.slug')"
:hint="t('workspaceSettings.fields.slugHint')"
:disabled="workspaceStore.isUpdating"
variant="outlined"
persistent-hint
/>
<label class="field">
<span>{{ t('workspaceSettings.fields.timeZone') }}</span>
<TimeZoneSelect

View File

@@ -504,7 +504,19 @@
"title": "Connections",
"description": "Organization-level connectors and data mappings.",
"placeholderTitle": "No organization connections configured",
"placeholderText": "Connector authorization flows are intentionally out of scope for this UI shell."
"placeholderText": "Connector authorization flows are intentionally out of scope for this UI shell.",
"googleDrive": {
"title": "Google Drive DAM",
"description": "Use an organization Drive folder as the media library backing store. Workspace media is organized by workspace slug.",
"enabled": "Use Google Drive as the DAM backing store",
"rootFolderName": "Root folder name",
"rootFolderId": "Root folder ID",
"rootFolderUrl": "Root folder URL",
"save": "Save Google Drive DAM",
"saved": "Google Drive DAM configuration saved.",
"required": "Root folder name, ID, and URL are required when Google Drive DAM is enabled.",
"saveFailed": "Google Drive DAM configuration could not be saved."
}
},
"workspaces": {
"title": "Workspaces",
@@ -1120,6 +1132,8 @@
},
"fields": {
"name": "Workspace name",
"slug": "Workspace slug",
"slugHint": "Used as the folder name under the organization DAM root.",
"timeZone": "Time zone",
"memberEmail": "Member email",
"memberRole": "Role"
@@ -1295,6 +1309,17 @@
"organizeLibrary": "Review, tag, and reuse media from one workspace-level place."
},
"statusLabel": "Status",
"loading": "Loading media library...",
"notConfigured": "Google Drive DAM not configured",
"notConfiguredDescription": "Configure the organization Google Drive DAM connection before this workspace can resolve a backing folder.",
"damRootTitle": "DAM folder",
"workspaceSlug": "Workspace slug: {slug}",
"configuredTitle": "Google Drive backing store configured",
"configuredDescription": "Socialize is using the organization Drive root and this workspace slug to resolve media library metadata.",
"assetsTitle": "Workspace assets",
"assetsDescription": "Google Drive assets currently linked to content in this workspace.",
"emptyAssets": "No workspace assets have been linked yet.",
"openDrive": "Open in Drive",
"pendingTitle": "Management UI pending",
"pendingDescription": "The navigation and page entry point are in place. Next step is wiring actual Drive sync, listing, filters, and asset actions."
},

View File

@@ -504,7 +504,19 @@
"title": "Connexions",
"description": "Connecteurs et regles de donnees au niveau de l'organisation.",
"placeholderTitle": "Aucune connexion d'organisation configuree",
"placeholderText": "Les flux d'autorisation des connecteurs sont volontairement hors portee de cette interface."
"placeholderText": "Les flux d'autorisation des connecteurs sont volontairement hors portee de cette interface.",
"googleDrive": {
"title": "DAM Google Drive",
"description": "Utilisez un dossier Drive de l'organisation comme stockage de la bibliotheque media. Les medias sont organises par slug d'espace.",
"enabled": "Utiliser Google Drive comme stockage DAM",
"rootFolderName": "Nom du dossier racine",
"rootFolderId": "ID du dossier racine",
"rootFolderUrl": "URL du dossier racine",
"save": "Enregistrer le DAM Google Drive",
"saved": "Configuration DAM Google Drive enregistree.",
"required": "Le nom, l'ID et l'URL du dossier racine sont requis quand le DAM Google Drive est active.",
"saveFailed": "La configuration DAM Google Drive n'a pas pu etre enregistree."
}
},
"workspaces": {
"title": "Espaces",
@@ -1120,6 +1132,8 @@
},
"fields": {
"name": "Nom de l'espace",
"slug": "Slug de l'espace",
"slugHint": "Utilise comme nom de dossier sous la racine DAM de l'organisation.",
"timeZone": "Fuseau horaire",
"memberEmail": "Email du membre",
"memberRole": "Rôle"
@@ -1295,6 +1309,17 @@
"organizeLibrary": "Reviser, etiqueter et reutiliser les medias depuis un seul endroit au niveau de l'espace."
},
"statusLabel": "Statut",
"loading": "Chargement de la bibliotheque media...",
"notConfigured": "DAM Google Drive non configure",
"notConfiguredDescription": "Configurez la connexion DAM Google Drive de l'organisation avant de resoudre le dossier de cet espace.",
"damRootTitle": "Dossier DAM",
"workspaceSlug": "Slug de l'espace : {slug}",
"configuredTitle": "Stockage Google Drive configure",
"configuredDescription": "Socialize utilise la racine Drive de l'organisation et le slug de cet espace pour resoudre les metadonnees media.",
"assetsTitle": "Ressources de l'espace",
"assetsDescription": "Ressources Google Drive actuellement liees au contenu de cet espace.",
"emptyAssets": "Aucune ressource d'espace n'a encore ete liee.",
"openDrive": "Ouvrir dans Drive",
"pendingTitle": "Interface de gestion en attente",
"pendingDescription": "L'entree de navigation et la page sont en place. La prochaine etape est de brancher la vraie synchro Drive, le listing, les filtres et les actions sur les ressources."
},