From 0fbb30bb4f904a799cda2b33bf837681776101ef Mon Sep 17 00:00:00 2001 From: Jonathan Bourdon Date: Fri, 8 May 2026 11:36:30 -0400 Subject: [PATCH] feat: add google drive dam foundation --- .../TestData/TestDataSeedExtensions.cs | 13 + ...02_AddGoogleDriveDamFoundation.Designer.cs | 2657 +++++++++++++++++ ...60508152102_AddGoogleDriveDamFoundation.cs | 139 + .../Migrations/AppDbContextModelSnapshot.cs | 29 + .../Modules/Assets/Data/Asset.cs | 1 + .../Assets/Data/AssetModelConfiguration.cs | 1 + .../Assets/Handlers/CreateGoogleDriveAsset.cs | 25 + .../Modules/Assets/Handlers/GetAssets.cs | 2 + .../Assets/Handlers/GetWorkspaceDam.cs | 123 + .../Organizations/Data/Organization.cs | 4 + .../Data/OrganizationModelConfiguration.cs | 4 + .../Handlers/OrganizationDtos.cs | 18 + .../UpdateGoogleDriveDamConfiguration.cs | 87 + .../Modules/Workspaces/Data/Workspace.cs | 1 + .../Data/WorkspaceModelConfiguration.cs | 2 + .../Workspaces/Handlers/CreateWorkspace.cs | 10 + .../Workspaces/Handlers/GetWorkspaces.cs | 2 + .../Workspaces/Handlers/UpdateWorkspace.cs | 9 + .../Services/WorkspaceSlugGenerator.cs | 64 + docs/FEATURES/digital-asset-management.md | 56 + .../008-google-drive-backed-dam-foundation.md | 45 + .../content/stores/mediaLibraryStore.js | 41 + .../content/views/MediaLibraryView.vue | 114 +- .../organizations/stores/organizationStore.js | 42 + .../views/OrganizationSettingsView.vue | 89 +- .../views/WorkspaceSettingsView.vue | 16 +- frontend/src/locales/en.json | 27 +- frontend/src/locales/fr.json | 27 +- 28 files changed, 3622 insertions(+), 26 deletions(-) create mode 100644 backend/src/Socialize.Api/Migrations/20260508152102_AddGoogleDriveDamFoundation.Designer.cs create mode 100644 backend/src/Socialize.Api/Migrations/20260508152102_AddGoogleDriveDamFoundation.cs create mode 100644 backend/src/Socialize.Api/Modules/Assets/Handlers/GetWorkspaceDam.cs create mode 100644 backend/src/Socialize.Api/Modules/Organizations/Handlers/UpdateGoogleDriveDamConfiguration.cs create mode 100644 backend/src/Socialize.Api/Modules/Workspaces/Services/WorkspaceSlugGenerator.cs create mode 100644 docs/FEATURES/digital-asset-management.md create mode 100644 docs/TASKS/content/008-google-drive-backed-dam-foundation.md create mode 100644 frontend/src/features/content/stores/mediaLibraryStore.js diff --git a/backend/src/Socialize.Api/Infrastructure/TestData/TestDataSeedExtensions.cs b/backend/src/Socialize.Api/Infrastructure/TestData/TestDataSeedExtensions.cs index 304592c3..0a718f0b 100644 --- a/backend/src/Socialize.Api/Infrastructure/TestData/TestDataSeedExtensions.cs +++ b/backend/src/Socialize.Api/Infrastructure/TestData/TestDataSeedExtensions.cs @@ -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; diff --git a/backend/src/Socialize.Api/Migrations/20260508152102_AddGoogleDriveDamFoundation.Designer.cs b/backend/src/Socialize.Api/Migrations/20260508152102_AddGoogleDriveDamFoundation.Designer.cs new file mode 100644 index 00000000..ef8d8c9d --- /dev/null +++ b/backend/src/Socialize.Api/Migrations/20260508152102_AddGoogleDriveDamFoundation.Designer.cs @@ -0,0 +1,2657 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Socialize.Api.Data; + +#nullable disable + +namespace Socialize.Api.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260508152102_AddGoogleDriveDamFoundation")] + partial class AddGoogleDriveDamFoundation + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Approvals.Data.ApprovalDecision", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovalRequestId") + .HasColumnType("uuid"); + + b.Property("Comment") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DecidedByEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("DecidedByName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("DecidedByUserId") + .HasColumnType("uuid"); + + b.Property("Decision") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("ApprovalRequestId"); + + b.ToTable("ApprovalDecisions", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Approvals.Data.ApprovalRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessToken") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ContentItemId") + .HasColumnType("uuid"); + + b.Property("DueAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RequestedByUserId") + .HasColumnType("uuid"); + + b.Property("ReviewerEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ReviewerName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Stage") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("State") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("WorkflowInstanceId") + .HasColumnType("uuid"); + + b.Property("WorkflowStepRequiredApproverCount") + .HasColumnType("integer"); + + b.Property("WorkflowStepSortOrder") + .HasColumnType("integer"); + + b.Property("WorkflowStepTargetType") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("WorkflowStepTargetValue") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ContentItemId"); + + b.HasIndex("ReviewerEmail"); + + b.HasIndex("WorkflowInstanceId"); + + b.HasIndex("WorkspaceId"); + + b.ToTable("ApprovalRequests", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Approvals.Data.ApprovalWorkflowInstance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovalMode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ContentItemId") + .HasColumnType("uuid"); + + b.Property("StartedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("State") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ContentItemId"); + + b.HasIndex("WorkspaceId"); + + b.HasIndex("ContentItemId", "State") + .IsUnique() + .HasFilter("\"State\" = 'Pending'"); + + b.ToTable("ApprovalWorkflowInstances", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Approvals.Data.WorkspaceApprovalStepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("RequiredApproverCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TargetValue") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorkspaceId"); + + b.HasIndex("WorkspaceId", "SortOrder") + .IsUnique(); + + b.ToTable("WorkspaceApprovalStepConfigurations", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Assets.Data.Asset", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssetType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ContentItemId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CurrentRevisionNumber") + .HasColumnType("integer"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("GoogleDriveFileId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("GoogleDriveLink") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("GoogleDriveWorkspaceFolderPath") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("PreviewUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("SourceType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ContentItemId"); + + b.HasIndex("WorkspaceId"); + + b.ToTable("Assets", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Assets.Data.AssetRevision", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssetId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatedByUserId") + .HasColumnType("uuid"); + + b.Property("Notes") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("PreviewUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("RevisionNumber") + .HasColumnType("integer"); + + b.Property("SourceReference") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.HasKey("Id"); + + b.HasIndex("AssetId"); + + b.HasIndex("AssetId", "RevisionNumber") + .IsUnique(); + + b.ToTable("AssetRevisions", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.CalendarIntegrations.Data.CalendarCatalogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Country") + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CultureOrReligion") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("DefaultColor") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("ProviderName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Region") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SourceUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TrustLevel") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("Category"); + + b.HasIndex("Country"); + + b.HasIndex("ProviderName"); + + b.ToTable("CalendarCatalogEntries", (string)null); + + b.HasData( + new + { + Id = new Guid("10000000-0000-0000-0000-000000000001"), + Category = "public-holiday", + Country = "US", + CreatedAt = new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DefaultColor = "#2F80ED", + Description = "Federal public holiday calendar for the United States.", + Language = "en", + ProviderName = "Nager.Date", + SourceUrl = "https://date.nager.at/api/v3/PublicHolidays/2026/US", + Title = "United States Public Holidays", + TrustLevel = "Verified" + }, + new + { + Id = new Guid("10000000-0000-0000-0000-000000000002"), + Category = "public-holiday", + Country = "CA", + CreatedAt = new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DefaultColor = "#2F80ED", + Description = "Public holiday calendar for Canada.", + Language = "en", + ProviderName = "Nager.Date", + SourceUrl = "https://date.nager.at/api/v3/PublicHolidays/2026/CA", + Title = "Canada Public Holidays", + TrustLevel = "Verified" + }, + new + { + Id = new Guid("10000000-0000-0000-0000-000000000003"), + Category = "marketing-moment", + CreatedAt = new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DefaultColor = "#9B51E0", + Description = "Common retail, awareness, and social planning moments.", + Language = "en", + ProviderName = "Socialize", + SourceUrl = "https://example.com/socialize/marketing-moments.ics", + Title = "Common Marketing Moments", + TrustLevel = "Maintained" + }); + }); + + modelBuilder.Entity("Socialize.Api.Modules.CalendarIntegrations.Data.CalendarEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CalendarSourceId") + .HasColumnType("uuid"); + + b.Property("Description") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("EndLocalDateTime") + .HasColumnType("timestamp with time zone"); + + b.Property("EndUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ImportedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsAllDay") + .HasColumnType("boolean"); + + b.Property("IsFloatingTime") + .HasColumnType("boolean"); + + b.Property("Location") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RecurrenceId") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("SourceEventUid") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("SourceLastModifiedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("StartDate") + .HasColumnType("date"); + + b.Property("StartLocalDateTime") + .HasColumnType("timestamp with time zone"); + + b.Property("StartUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TimeZoneId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("CalendarSourceId"); + + b.HasIndex("CalendarSourceId", "SourceEventUid", "StartDate") + .IsUnique(); + + b.ToTable("CalendarEvents", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.CalendarIntegrations.Data.CalendarSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CatalogSourceReference") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Color") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DisplayTitle") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("InheritanceMode") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("LastAttemptedSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSuccessfulSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSyncError") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SourceUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("Scope"); + + b.HasIndex("UserId"); + + b.HasIndex("WorkspaceId"); + + b.ToTable("CalendarSources", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.CalendarIntegrations.Data.UserCalendarExportFeed", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Token") + .HasMaxLength(96) + .HasColumnType("character varying(96)"); + + b.Property("TokenHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("UserCalendarExportFeeds", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Campaigns.Data.Campaign", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClientId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Description") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Notes") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ClientId"); + + b.HasIndex("WorkspaceId"); + + b.HasIndex("ClientId", "Name") + .IsUnique(); + + b.ToTable("Campaigns", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Channels.Data.Channel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("ExternalUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Handle") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Network") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorkspaceId"); + + b.HasIndex("WorkspaceId", "Network", "Name") + .IsUnique(); + + b.ToTable("Channels", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Clients.Data.Client", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PortraitUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("PrimaryContactEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PrimaryContactName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PrimaryContactPortraitUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorkspaceId"); + + b.HasIndex("WorkspaceId", "Name") + .IsUnique(); + + b.ToTable("Clients", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Comments.Data.Comment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AttachmentBlobContainerName") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("AttachmentBlobName") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("AttachmentBlobUrl") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("AttachmentContentType") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("AttachmentFileName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("AttachmentSizeBytes") + .HasColumnType("bigint"); + + b.Property("AuthorDisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("AuthorEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("AuthorUserId") + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ContentItemId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("ParentCommentId") + .HasColumnType("uuid"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ContentItemId"); + + b.HasIndex("ParentCommentId"); + + b.HasIndex("WorkspaceId"); + + b.ToTable("Comments", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.ContentItems.Data.ContentItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CampaignId") + .HasColumnType("uuid"); + + b.Property("ClientId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CurrentRevisionLabel") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CurrentRevisionNumber") + .HasColumnType("integer"); + + b.Property("DueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Hashtags") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("PublicationMessage") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("PublicationTargets") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CampaignId"); + + b.HasIndex("ClientId"); + + b.HasIndex("WorkspaceId"); + + b.ToTable("ContentItems", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.ContentItems.Data.ContentItemActivityEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActorEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ActorUserId") + .HasColumnType("uuid"); + + b.Property("ContentItemId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("EntityId") + .HasColumnType("uuid"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("MetadataJson") + .HasColumnType("jsonb"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ContentItemId"); + + b.HasIndex("WorkspaceId"); + + b.HasIndex("ContentItemId", "CreatedAt"); + + b.ToTable("ContentItemActivityEntries", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.ContentItems.Data.ContentItemRevision", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChangeSummary") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ContentItemId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatedByUserId") + .HasColumnType("uuid"); + + b.Property("Hashtags") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("PublicationMessage") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("PublicationTargets") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RevisionLabel") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("RevisionNumber") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("ContentItemId"); + + b.HasIndex("ContentItemId", "RevisionNumber") + .IsUnique(); + + b.ToTable("ContentItemRevisions", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackActivityEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActivityType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ActorDisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ActorEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ActorUserId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("FeedbackReportId") + .HasColumnType("uuid"); + + b.Property("FromValue") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Note") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("ToValue") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("FeedbackReportId"); + + b.ToTable("FeedbackActivityEntries", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackComment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthorDisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("AuthorEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("AuthorRole") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("AuthorUserId") + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("character varying(8000)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("FeedbackReportId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AuthorUserId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("FeedbackReportId"); + + b.ToTable("FeedbackComments", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackReport", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppVersion") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("BrowserUserAgent") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("CampaignId") + .HasColumnType("uuid"); + + b.Property("CampaignName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CancellationReason") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("CancelledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CancelledByUserId") + .HasColumnType("uuid"); + + b.Property("ClientId") + .HasColumnType("uuid"); + + b.Property("ClientName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ContentItemId") + .HasColumnType("uuid"); + + b.Property("ContentItemTitle") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("character varying(8000)"); + + b.Property("LastActivityAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReporterDisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ReporterEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ReporterUserId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SubmittedPath") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ViewportHeight") + .HasColumnType("integer"); + + b.Property("ViewportWidth") + .HasColumnType("integer"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.Property("WorkspaceName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("CampaignId"); + + b.HasIndex("ClientId"); + + b.HasIndex("ContentItemId"); + + b.HasIndex("LastActivityAt"); + + b.HasIndex("ReporterUserId"); + + b.HasIndex("Status"); + + b.HasIndex("Type"); + + b.HasIndex("WorkspaceId"); + + b.ToTable("FeedbackReports", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackScreenshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BlobContainerName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("FeedbackReportId") + .HasColumnType("uuid"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("FeedbackReportId") + .IsUnique(); + + b.ToTable("FeedbackScreenshots", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("FeedbackReportId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName"); + + b.HasIndex("FeedbackReportId", "NormalizedName") + .IsUnique(); + + b.ToTable("FeedbackTags", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Identity.Data.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Identity.Data.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("Address") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Alias") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("BirthDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("FacebookId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Firstname") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("GoogleId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LastAuthenticatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Lastname") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("PortraitUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("RefreshToken") + .HasMaxLength(44) + .HasColumnType("character varying(44)"); + + b.Property("RefreshTokenExpiryTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Notifications.Data.NotificationEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContentItemId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("EntityId") + .HasColumnType("uuid"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("MetadataJson") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ReadAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RecipientEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RecipientUserId") + .HasColumnType("uuid"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ContentItemId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("RecipientUserId"); + + b.HasIndex("WorkspaceId"); + + b.ToTable("NotificationEvents", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Organizations.Data.Organization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("GoogleDriveRootFolderId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("GoogleDriveRootFolderName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("GoogleDriveRootFolderUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("IsGoogleDriveDamEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("LogoUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("MembershipTierId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValue(new Guid("20000000-0000-0000-0000-000000000001")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OwnerUserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("MembershipTierId"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Organizations", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Organizations.Data.OrganizationMembership", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.HasIndex("OrganizationId", "UserId") + .IsUnique(); + + b.ToTable("OrganizationMemberships", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Organizations.Data.OrganizationMembershipTier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActiveContentLimit") + .HasColumnType("integer"); + + b.Property("ExternalReviewerLimit") + .HasColumnType("integer"); + + b.Property("IsCustom") + .HasColumnType("boolean"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MemberLimit") + .HasColumnType("integer"); + + b.Property("MonthlyPriceCents") + .HasColumnType("integer"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("WorkspaceLimit") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.HasIndex("SortOrder"); + + b.ToTable("OrganizationMembershipTiers", (string)null); + + b.HasData( + new + { + Id = new Guid("20000000-0000-0000-0000-000000000001"), + ActiveContentLimit = 3, + ExternalReviewerLimit = 1, + IsCustom = false, + Key = "free", + MemberLimit = 2, + MonthlyPriceCents = 0, + SortOrder = 10, + WorkspaceLimit = 1 + }, + new + { + Id = new Guid("20000000-0000-0000-0000-000000000002"), + ActiveContentLimit = 25, + ExternalReviewerLimit = 10, + IsCustom = false, + Key = "freelance", + MemberLimit = 5, + MonthlyPriceCents = 1900, + SortOrder = 20, + WorkspaceLimit = 3 + }, + new + { + Id = new Guid("20000000-0000-0000-0000-000000000003"), + ActiveContentLimit = 250, + IsCustom = false, + Key = "agency", + MemberLimit = 25, + MonthlyPriceCents = 7900, + SortOrder = 30, + WorkspaceLimit = 15 + }, + new + { + Id = new Guid("20000000-0000-0000-0000-000000000004"), + IsCustom = true, + Key = "enterprise", + SortOrder = 40 + }); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Organizations.Data.OrganizationMembershipTierTranslation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("MembershipTierId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("MembershipTierId", "Culture") + .IsUnique(); + + b.ToTable("OrganizationMembershipTierTranslations", (string)null); + + b.HasData( + new + { + Id = new Guid("20000000-0000-0001-0000-000000000001"), + Culture = "en", + Description = "For trying Socialize on one real approval workflow.", + MembershipTierId = new Guid("20000000-0000-0000-0000-000000000001"), + Name = "Free" + }, + new + { + Id = new Guid("20000000-0000-0001-0000-000000000002"), + Culture = "fr", + Description = "Pour essayer Socialize sur un vrai workflow d'approbation.", + MembershipTierId = new Guid("20000000-0000-0000-0000-000000000001"), + Name = "Free" + }, + new + { + Id = new Guid("20000000-0000-0001-0000-000000000003"), + Culture = "en", + Description = "For solo operators managing recurring client reviews.", + MembershipTierId = new Guid("20000000-0000-0000-0000-000000000002"), + Name = "Freelance" + }, + new + { + Id = new Guid("20000000-0000-0001-0000-000000000004"), + Culture = "fr", + Description = "Pour les independants qui gerent des revisions client recurrentes.", + MembershipTierId = new Guid("20000000-0000-0000-0000-000000000002"), + Name = "Freelance" + }, + new + { + Id = new Guid("20000000-0000-0001-0000-000000000005"), + Culture = "en", + Description = "For agencies that need repeatable client approval operations.", + MembershipTierId = new Guid("20000000-0000-0000-0000-000000000003"), + Name = "Agency" + }, + new + { + Id = new Guid("20000000-0000-0001-0000-000000000006"), + Culture = "fr", + Description = "Pour les agences qui veulent des operations d'approbation client repetables.", + MembershipTierId = new Guid("20000000-0000-0000-0000-000000000003"), + Name = "Agency" + }, + new + { + Id = new Guid("20000000-0000-0001-0000-000000000007"), + Culture = "en", + Description = "For larger organizations with governance and access needs.", + MembershipTierId = new Guid("20000000-0000-0000-0000-000000000004"), + Name = "Enterprise" + }, + new + { + Id = new Guid("20000000-0000-0001-0000-000000000008"), + Culture = "fr", + Description = "Pour les grandes organisations avec des besoins de gouvernance et d'acces.", + MembershipTierId = new Guid("20000000-0000-0000-0000-000000000004"), + Name = "Enterprise" + }); + }); + + modelBuilder.Entity("Socialize.Api.Modules.ReleaseCommunications.Data.ReleaseCommit", b => + { + b.Property("Sha") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("AuthorEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("AuthorName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("AuthoredAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CommittedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CommunicationStatus") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("DeploymentLabel") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ExternalUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("ImportedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("ReleaseUpdateId") + .HasColumnType("uuid"); + + b.Property("ShortSha") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("SourceBranch") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Sha"); + + b.HasIndex("CommittedAt"); + + b.HasIndex("CommunicationStatus"); + + b.HasIndex("ReleaseUpdateId"); + + b.ToTable("ReleaseCommits", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.ReleaseCommunications.Data.ReleaseUpdate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ArchivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Audience") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Body") + .HasMaxLength(8000) + .HasColumnType("character varying(8000)"); + + b.Property("BuildVersion") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CommitRange") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatedByUserId") + .HasColumnType("uuid"); + + b.Property("DeploymentLabel") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Importance") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ManualEmailAudience") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ManualEmailRecipientCount") + .HasColumnType("integer"); + + b.Property("ManualEmailSentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ManualEmailSentByUserId") + .HasColumnType("uuid"); + + b.Property("PublishedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Audience"); + + b.HasIndex("CreatedByUserId"); + + b.HasIndex("PublishedAt"); + + b.HasIndex("Status"); + + b.ToTable("ReleaseUpdates", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.ReleaseCommunications.Data.ReleaseUpdateEmailDigestReceipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("SentAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("UpdateCount") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SentAt"); + + b.HasIndex("UserId"); + + b.ToTable("ReleaseUpdateEmailDigestReceipts", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.ReleaseCommunications.Data.ReleaseUpdateReadReceipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ReadAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("ReleaseUpdateId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("ReleaseUpdateId", "UserId") + .IsUnique(); + + b.ToTable("ReleaseUpdateReadReceipts", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Workspaces.Data.Workspace", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovalMode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasDefaultValue("Required"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("LockContentAfterApproval") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("LogoUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("OwnerUserId") + .HasColumnType("uuid"); + + b.Property("SchedulePostsAutomaticallyOnApproval") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("SendAutomaticApprovalReminders") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(96) + .HasColumnType("character varying(96)"); + + b.Property("TimeZone") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("OwnerUserId"); + + b.HasIndex("OrganizationId", "Slug") + .IsUnique(); + + b.ToTable("Workspaces", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Workspaces.Data.WorkspaceInvite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("InvitedByUserId") + .HasColumnType("uuid"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorkspaceId"); + + b.HasIndex("WorkspaceId", "Email", "Status"); + + b.ToTable("WorkspaceInvites", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Socialize.Api.Modules.Identity.Data.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Socialize.Api.Modules.Identity.Data.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Socialize.Api.Modules.Identity.Data.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Socialize.Api.Modules.Identity.Data.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Socialize.Api.Modules.Identity.Data.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Socialize.Api.Modules.Identity.Data.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Approvals.Data.ApprovalDecision", b => + { + b.HasOne("Socialize.Api.Modules.Approvals.Data.ApprovalRequest", null) + .WithMany() + .HasForeignKey("ApprovalRequestId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Approvals.Data.ApprovalRequest", b => + { + b.HasOne("Socialize.Api.Modules.ContentItems.Data.ContentItem", null) + .WithMany() + .HasForeignKey("ContentItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Socialize.Api.Modules.Approvals.Data.ApprovalWorkflowInstance", null) + .WithMany() + .HasForeignKey("WorkflowInstanceId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Socialize.Api.Modules.Workspaces.Data.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Approvals.Data.ApprovalWorkflowInstance", b => + { + b.HasOne("Socialize.Api.Modules.ContentItems.Data.ContentItem", null) + .WithMany() + .HasForeignKey("ContentItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Socialize.Api.Modules.Workspaces.Data.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Approvals.Data.WorkspaceApprovalStepConfiguration", b => + { + b.HasOne("Socialize.Api.Modules.Workspaces.Data.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Assets.Data.Asset", b => + { + b.HasOne("Socialize.Api.Modules.ContentItems.Data.ContentItem", null) + .WithMany() + .HasForeignKey("ContentItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Socialize.Api.Modules.Workspaces.Data.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Assets.Data.AssetRevision", b => + { + b.HasOne("Socialize.Api.Modules.Assets.Data.Asset", null) + .WithMany() + .HasForeignKey("AssetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.CalendarIntegrations.Data.CalendarEvent", b => + { + b.HasOne("Socialize.Api.Modules.CalendarIntegrations.Data.CalendarSource", null) + .WithMany() + .HasForeignKey("CalendarSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Campaigns.Data.Campaign", b => + { + b.HasOne("Socialize.Api.Modules.Clients.Data.Client", null) + .WithMany() + .HasForeignKey("ClientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Socialize.Api.Modules.Workspaces.Data.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Channels.Data.Channel", b => + { + b.HasOne("Socialize.Api.Modules.Workspaces.Data.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Clients.Data.Client", b => + { + b.HasOne("Socialize.Api.Modules.Workspaces.Data.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Comments.Data.Comment", b => + { + b.HasOne("Socialize.Api.Modules.ContentItems.Data.ContentItem", null) + .WithMany() + .HasForeignKey("ContentItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Socialize.Api.Modules.Comments.Data.Comment", null) + .WithMany() + .HasForeignKey("ParentCommentId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Socialize.Api.Modules.Workspaces.Data.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.ContentItems.Data.ContentItem", b => + { + b.HasOne("Socialize.Api.Modules.Campaigns.Data.Campaign", null) + .WithMany() + .HasForeignKey("CampaignId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Socialize.Api.Modules.Clients.Data.Client", null) + .WithMany() + .HasForeignKey("ClientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Socialize.Api.Modules.Workspaces.Data.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.ContentItems.Data.ContentItemActivityEntry", b => + { + b.HasOne("Socialize.Api.Modules.ContentItems.Data.ContentItem", null) + .WithMany() + .HasForeignKey("ContentItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Socialize.Api.Modules.Workspaces.Data.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.ContentItems.Data.ContentItemRevision", b => + { + b.HasOne("Socialize.Api.Modules.ContentItems.Data.ContentItem", null) + .WithMany() + .HasForeignKey("ContentItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackActivityEntry", b => + { + b.HasOne("Socialize.Api.Modules.Feedback.Data.FeedbackReport", "FeedbackReport") + .WithMany("ActivityEntries") + .HasForeignKey("FeedbackReportId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeedbackReport"); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackComment", b => + { + b.HasOne("Socialize.Api.Modules.Feedback.Data.FeedbackReport", "FeedbackReport") + .WithMany("Comments") + .HasForeignKey("FeedbackReportId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeedbackReport"); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackReport", b => + { + b.HasOne("Socialize.Api.Modules.Campaigns.Data.Campaign", null) + .WithMany() + .HasForeignKey("CampaignId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Socialize.Api.Modules.Clients.Data.Client", null) + .WithMany() + .HasForeignKey("ClientId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Socialize.Api.Modules.ContentItems.Data.ContentItem", null) + .WithMany() + .HasForeignKey("ContentItemId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Socialize.Api.Modules.Workspaces.Data.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackScreenshot", b => + { + b.HasOne("Socialize.Api.Modules.Feedback.Data.FeedbackReport", "FeedbackReport") + .WithOne("Screenshot") + .HasForeignKey("Socialize.Api.Modules.Feedback.Data.FeedbackScreenshot", "FeedbackReportId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeedbackReport"); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackTag", b => + { + b.HasOne("Socialize.Api.Modules.Feedback.Data.FeedbackReport", "FeedbackReport") + .WithMany("Tags") + .HasForeignKey("FeedbackReportId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeedbackReport"); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Notifications.Data.NotificationEvent", b => + { + b.HasOne("Socialize.Api.Modules.ContentItems.Data.ContentItem", null) + .WithMany() + .HasForeignKey("ContentItemId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Socialize.Api.Modules.Workspaces.Data.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Organizations.Data.Organization", b => + { + b.HasOne("Socialize.Api.Modules.Organizations.Data.OrganizationMembershipTier", null) + .WithMany() + .HasForeignKey("MembershipTierId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Organizations.Data.OrganizationMembership", b => + { + b.HasOne("Socialize.Api.Modules.Organizations.Data.Organization", null) + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Organizations.Data.OrganizationMembershipTierTranslation", b => + { + b.HasOne("Socialize.Api.Modules.Organizations.Data.OrganizationMembershipTier", null) + .WithMany() + .HasForeignKey("MembershipTierId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.ReleaseCommunications.Data.ReleaseCommit", b => + { + b.HasOne("Socialize.Api.Modules.ReleaseCommunications.Data.ReleaseUpdate", "ReleaseUpdate") + .WithMany() + .HasForeignKey("ReleaseUpdateId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("ReleaseUpdate"); + }); + + modelBuilder.Entity("Socialize.Api.Modules.ReleaseCommunications.Data.ReleaseUpdateReadReceipt", b => + { + b.HasOne("Socialize.Api.Modules.ReleaseCommunications.Data.ReleaseUpdate", "ReleaseUpdate") + .WithMany("ReadReceipts") + .HasForeignKey("ReleaseUpdateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ReleaseUpdate"); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Workspaces.Data.Workspace", b => + { + b.HasOne("Socialize.Api.Modules.Organizations.Data.Organization", null) + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Workspaces.Data.WorkspaceInvite", b => + { + b.HasOne("Socialize.Api.Modules.Workspaces.Data.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackReport", b => + { + b.Navigation("ActivityEntries"); + + b.Navigation("Comments"); + + b.Navigation("Screenshot"); + + b.Navigation("Tags"); + }); + + modelBuilder.Entity("Socialize.Api.Modules.ReleaseCommunications.Data.ReleaseUpdate", b => + { + b.Navigation("ReadReceipts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Socialize.Api/Migrations/20260508152102_AddGoogleDriveDamFoundation.cs b/backend/src/Socialize.Api/Migrations/20260508152102_AddGoogleDriveDamFoundation.cs new file mode 100644 index 00000000..92830b0e --- /dev/null +++ b/backend/src/Socialize.Api/Migrations/20260508152102_AddGoogleDriveDamFoundation.cs @@ -0,0 +1,139 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Socialize.Api.Migrations +{ + /// + internal partial class AddGoogleDriveDamFoundation : Migration + { + private static readonly string[] WorkspaceOrganizationSlugIndexColumns = + [ + "OrganizationId", + "Slug", + ]; + + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + ArgumentNullException.ThrowIfNull(migrationBuilder); + + migrationBuilder.AddColumn( + 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( + name: "GoogleDriveRootFolderId", + table: "Organizations", + type: "character varying(256)", + maxLength: 256, + nullable: true); + + migrationBuilder.AddColumn( + name: "GoogleDriveRootFolderName", + table: "Organizations", + type: "character varying(256)", + maxLength: 256, + nullable: true); + + migrationBuilder.AddColumn( + name: "GoogleDriveRootFolderUrl", + table: "Organizations", + type: "character varying(2048)", + maxLength: 2048, + nullable: true); + + migrationBuilder.AddColumn( + name: "IsGoogleDriveDamEnabled", + table: "Organizations", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + 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); + } + + /// + 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"); + } + } +} diff --git a/backend/src/Socialize.Api/Migrations/AppDbContextModelSnapshot.cs b/backend/src/Socialize.Api/Migrations/AppDbContextModelSnapshot.cs index 2182df43..75522c66 100644 --- a/backend/src/Socialize.Api/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/Socialize.Api/Migrations/AppDbContextModelSnapshot.cs @@ -374,6 +374,10 @@ namespace Socialize.Api.Migrations .HasMaxLength(2048) .HasColumnType("character varying(2048)"); + b.Property("GoogleDriveWorkspaceFolderPath") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + b.Property("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("GoogleDriveRootFolderId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("GoogleDriveRootFolderName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("GoogleDriveRootFolderUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("IsGoogleDriveDamEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + b.Property("LogoUrl") .HasMaxLength(2048) .HasColumnType("character varying(2048)"); @@ -2167,6 +2188,11 @@ namespace Socialize.Api.Migrations .HasColumnType("boolean") .HasDefaultValue(false); + b.Property("Slug") + .IsRequired() + .HasMaxLength(96) + .HasColumnType("character varying(96)"); + b.Property("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); }); diff --git a/backend/src/Socialize.Api/Modules/Assets/Data/Asset.cs b/backend/src/Socialize.Api/Modules/Assets/Data/Asset.cs index e6871a63..99bf2931 100644 --- a/backend/src/Socialize.Api/Modules/Assets/Data/Asset.cs +++ b/backend/src/Socialize.Api/Modules/Assets/Data/Asset.cs @@ -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; } diff --git a/backend/src/Socialize.Api/Modules/Assets/Data/AssetModelConfiguration.cs b/backend/src/Socialize.Api/Modules/Assets/Data/AssetModelConfiguration.cs index 61467a07..6b555c34 100644 --- a/backend/src/Socialize.Api/Modules/Assets/Data/AssetModelConfiguration.cs +++ b/backend/src/Socialize.Api/Modules/Assets/Data/AssetModelConfiguration.cs @@ -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() diff --git a/backend/src/Socialize.Api/Modules/Assets/Handlers/CreateGoogleDriveAsset.cs b/backend/src/Socialize.Api/Modules/Assets/Handlers/CreateGoogleDriveAsset.cs index 23935ea5..6329a398 100644 --- a/backend/src/Socialize.Api/Modules/Assets/Handlers/CreateGoogleDriveAsset.cs +++ b/backend/src/Socialize.Api/Modules/Assets/Handlers/CreateGoogleDriveAsset.cs @@ -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, diff --git a/backend/src/Socialize.Api/Modules/Assets/Handlers/GetAssets.cs b/backend/src/Socialize.Api/Modules/Assets/Handlers/GetAssets.cs index 27e748b8..62c28a56 100644 --- a/backend/src/Socialize.Api/Modules/Assets/Handlers/GetAssets.cs +++ b/backend/src/Socialize.Api/Modules/Assets/Handlers/GetAssets.cs @@ -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, diff --git a/backend/src/Socialize.Api/Modules/Assets/Handlers/GetWorkspaceDam.cs b/backend/src/Socialize.Api/Modules/Assets/Handlers/GetWorkspaceDam.cs new file mode 100644 index 00000000..7eaf881a --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Assets/Handlers/GetWorkspaceDam.cs @@ -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 Assets); + +internal class GetWorkspaceDamHandler( + AppDbContext dbContext, + AccessScopeService accessScopeService) + : EndpointWithoutRequest +{ + 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("workspaceId"); + + Workspace? workspace = await dbContext.Workspaces + .SingleOrDefaultAsync(candidate => candidate.Id == workspaceId, ct); + if (workspace is null) + { + await SendNotFoundAsync(ct); + return; + } + + IReadOnlyCollection 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 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); + } +} diff --git a/backend/src/Socialize.Api/Modules/Organizations/Data/Organization.cs b/backend/src/Socialize.Api/Modules/Organizations/Data/Organization.cs index d7bc3948..9d4dcb4c 100644 --- a/backend/src/Socialize.Api/Modules/Organizations/Data/Organization.cs +++ b/backend/src/Socialize.Api/Modules/Organizations/Data/Organization.cs @@ -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; } diff --git a/backend/src/Socialize.Api/Modules/Organizations/Data/OrganizationModelConfiguration.cs b/backend/src/Socialize.Api/Modules/Organizations/Data/OrganizationModelConfiguration.cs index 92cdaa45..54abea57 100644 --- a/backend/src/Socialize.Api/Modules/Organizations/Data/OrganizationModelConfiguration.cs +++ b/backend/src/Socialize.Api/Modules/Organizations/Data/OrganizationModelConfiguration.cs @@ -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) diff --git a/backend/src/Socialize.Api/Modules/Organizations/Handlers/OrganizationDtos.cs b/backend/src/Socialize.Api/Modules/Organizations/Handlers/OrganizationDtos.cs index 1296abcc..3539cc9a 100644 --- a/backend/src/Socialize.Api/Modules/Organizations/Handlers/OrganizationDtos.cs +++ b/backend/src/Socialize.Api/Modules/Organizations/Handlers/OrganizationDtos.cs @@ -16,6 +16,7 @@ internal record OrganizationDto( Guid Id, string Name, string? LogoUrl, + OrganizationGoogleDriveDamConfigurationDto GoogleDriveDam, OrganizationMembershipTierDto? MembershipTier, Guid OwnerUserId, IReadOnlyCollection 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, diff --git a/backend/src/Socialize.Api/Modules/Organizations/Handlers/UpdateGoogleDriveDamConfiguration.cs b/backend/src/Socialize.Api/Modules/Organizations/Handlers/UpdateGoogleDriveDamConfiguration.cs new file mode 100644 index 00000000..5cb8941a --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Organizations/Handlers/UpdateGoogleDriveDamConfiguration.cs @@ -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 +{ + 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 +{ + 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("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(); + } +} diff --git a/backend/src/Socialize.Api/Modules/Workspaces/Data/Workspace.cs b/backend/src/Socialize.Api/Modules/Workspaces/Data/Workspace.cs index edb8561f..fa814026 100644 --- a/backend/src/Socialize.Api/Modules/Workspaces/Data/Workspace.cs +++ b/backend/src/Socialize.Api/Modules/Workspaces/Data/Workspace.cs @@ -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; } diff --git a/backend/src/Socialize.Api/Modules/Workspaces/Data/WorkspaceModelConfiguration.cs b/backend/src/Socialize.Api/Modules/Workspaces/Data/WorkspaceModelConfiguration.cs index be43f3e9..556df786 100644 --- a/backend/src/Socialize.Api/Modules/Workspaces/Data/WorkspaceModelConfiguration.cs +++ b/backend/src/Socialize.Api/Modules/Workspaces/Data/WorkspaceModelConfiguration.cs @@ -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() .WithMany() diff --git a/backend/src/Socialize.Api/Modules/Workspaces/Handlers/CreateWorkspace.cs b/backend/src/Socialize.Api/Modules/Workspaces/Handlers/CreateWorkspace.cs index 9061273e..b8d7e577 100644 --- a/backend/src/Socialize.Api/Modules/Workspaces/Handlers/CreateWorkspace.cs +++ b/backend/src/Socialize.Api/Modules/Workspaces/Handlers/CreateWorkspace.cs @@ -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, diff --git a/backend/src/Socialize.Api/Modules/Workspaces/Handlers/GetWorkspaces.cs b/backend/src/Socialize.Api/Modules/Workspaces/Handlers/GetWorkspaces.cs index 86c2c76d..e1c33b92 100644 --- a/backend/src/Socialize.Api/Modules/Workspaces/Handlers/GetWorkspaces.cs +++ b/backend/src/Socialize.Api/Modules/Workspaces/Handlers/GetWorkspaces.cs @@ -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, diff --git a/backend/src/Socialize.Api/Modules/Workspaces/Handlers/UpdateWorkspace.cs b/backend/src/Socialize.Api/Modules/Workspaces/Handlers/UpdateWorkspace.cs index 31013c82..7c8bc7cb 100644 --- a/backend/src/Socialize.Api/Modules/Workspaces/Handlers/UpdateWorkspace.cs +++ b/backend/src/Socialize.Api/Modules/Workspaces/Handlers/UpdateWorkspace.cs @@ -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; diff --git a/backend/src/Socialize.Api/Modules/Workspaces/Services/WorkspaceSlugGenerator.cs b/backend/src/Socialize.Api/Modules/Workspaces/Services/WorkspaceSlugGenerator.cs new file mode 100644 index 00000000..9e3f4203 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Workspaces/Services/WorkspaceSlugGenerator.cs @@ -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 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; + } +} diff --git a/docs/FEATURES/digital-asset-management.md b/docs/FEATURES/digital-asset-management.md new file mode 100644 index 00000000..db0847b8 --- /dev/null +++ b/docs/FEATURES/digital-asset-management.md @@ -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 +// +``` + +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. diff --git a/docs/TASKS/content/008-google-drive-backed-dam-foundation.md b/docs/TASKS/content/008-google-drive-backed-dam-foundation.md new file mode 100644 index 00000000..8bf53030 --- /dev/null +++ b/docs/TASKS/content/008-google-drive-backed-dam-foundation.md @@ -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 `/`. +- [x] Existing manually linked Google Drive content assets remain supported. diff --git a/frontend/src/features/content/stores/mediaLibraryStore.js b/frontend/src/features/content/stores/mediaLibraryStore.js new file mode 100644 index 00000000..d0c38d4a --- /dev/null +++ b/frontend/src/features/content/stores/mediaLibraryStore.js @@ -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, + }; +}); diff --git a/frontend/src/features/content/views/MediaLibraryView.vue b/frontend/src/features/content/views/MediaLibraryView.vue index 006b0975..a54a2665 100644 --- a/frontend/src/features/content/views/MediaLibraryView.vue +++ b/frontend/src/features/content/views/MediaLibraryView.vue @@ -1,25 +1,35 @@