diff --git a/backend/src/Socialize.Api/Data/AppDbContext.cs b/backend/src/Socialize.Api/Data/AppDbContext.cs index b1dad79..4193579 100644 --- a/backend/src/Socialize.Api/Data/AppDbContext.cs +++ b/backend/src/Socialize.Api/Data/AppDbContext.cs @@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Socialize.Api.Modules.Approvals.Data; using Socialize.Api.Modules.Assets.Data; +using Socialize.Api.Modules.Channels.Data; using Socialize.Api.Modules.Clients.Data; using Socialize.Api.Modules.Comments.Data; using Socialize.Api.Modules.ContentItems.Data; @@ -22,6 +23,7 @@ public class AppDbContext( public DbSet OrganizationMemberships => Set(); public DbSet Workspaces => Set(); public DbSet WorkspaceInvites => Set(); + public DbSet Channels => Set(); public DbSet Clients => Set(); public DbSet Campaigns => Set(); public DbSet ContentItems => Set(); @@ -46,6 +48,7 @@ public class AppDbContext( builder.ConfigureOrganizationsModule(); builder.ConfigureWorkspacesModule(); + builder.ConfigureChannelsModule(); builder.ConfigureClientsModule(); builder.ConfigureCampaignsModule(); builder.ConfigureContentItemsModule(); diff --git a/backend/src/Socialize.Api/Infrastructure/Development/DevelopmentSeedExtensions.cs b/backend/src/Socialize.Api/Infrastructure/Development/DevelopmentSeedExtensions.cs index 1e9d3f3..1440bfc 100644 --- a/backend/src/Socialize.Api/Infrastructure/Development/DevelopmentSeedExtensions.cs +++ b/backend/src/Socialize.Api/Infrastructure/Development/DevelopmentSeedExtensions.cs @@ -6,6 +6,7 @@ using Socialize.Api.Modules.Identity.Contracts; using Socialize.Api.Modules.Identity.Data; using Socialize.Api.Modules.Assets.Data; using Socialize.Api.Modules.Approvals.Data; +using Socialize.Api.Modules.Channels.Data; using Socialize.Api.Modules.Comments.Data; using Socialize.Api.Modules.ContentItems.Data; using Socialize.Api.Modules.Clients.Data; @@ -23,10 +24,14 @@ public static class DevelopmentSeedExtensions { private static readonly Guid OrganizationId = Guid.Parse("99999999-9999-9999-9999-999999999999"); private static readonly Guid WorkspaceId = Guid.Parse("11111111-1111-1111-1111-111111111111"); + private static readonly Guid AtlasWorkspaceId = Guid.Parse("11111111-1111-1111-1111-222222222222"); private static readonly Guid ScopedClientId = Guid.Parse("22222222-2222-2222-2222-222222222222"); private static readonly Guid HiddenClientId = Guid.Parse("22222222-2222-2222-2222-333333333333"); private static readonly Guid ScopedCampaignId = Guid.Parse("33333333-3333-3333-3333-333333333333"); private static readonly Guid HiddenCampaignId = Guid.Parse("33333333-3333-3333-3333-444444444444"); + private static readonly Guid LumaInstagramChannelId = Guid.Parse("33333333-3333-3333-3333-000000000001"); + private static readonly Guid LumaTikTokChannelId = Guid.Parse("33333333-3333-3333-3333-000000000002"); + private static readonly Guid AtlasInstagramChannelId = Guid.Parse("33333333-3333-3333-3333-000000000003"); private static readonly Guid ScopedContentItemId = Guid.Parse("44444444-4444-4444-4444-444444444444"); private static readonly Guid HiddenContentItemId = Guid.Parse("44444444-4444-4444-4444-555555555555"); private static readonly Guid ScopedAssetId = Guid.Parse("55555555-5555-5555-5555-555555555555"); @@ -252,7 +257,7 @@ public static class DevelopmentSeedExtensions dbContext.Organizations.Add(organization); } - organization.Name = "Northstar Collective"; + organization.Name = "Northstar Agency"; organization.OwnerUserId = managerUserId; await UpsertOrganizationMembershipAsync( @@ -309,32 +314,31 @@ public static class DevelopmentSeedExtensions AppDbContext dbContext, CancellationToken cancellationToken) { - Workspace? workspace = await dbContext.Workspaces - .SingleOrDefaultAsync(candidate => candidate.Id == WorkspaceId, cancellationToken); - if (workspace is null) - { - workspace = new Workspace - { - Id = WorkspaceId, - Name = string.Empty, - TimeZone = string.Empty, - CreatedAt = DateTimeOffset.UtcNow, - }; - dbContext.Workspaces.Add(workspace); - } - - workspace.Name = "Northstar Studio"; - workspace.OrganizationId = OrganizationId; - workspace.OwnerUserId = managerUserId; - workspace.TimeZone = "America/Montreal"; - await dbContext.SaveChangesAsync(cancellationToken); + await UpsertWorkspaceAsync( + dbContext, + WorkspaceId, + OrganizationId, + managerUserId, + "Luma Coffee", + "America/Montreal", + "/images/seed/luma-coffee-logo.svg", + cancellationToken); + await UpsertWorkspaceAsync( + dbContext, + AtlasWorkspaceId, + OrganizationId, + managerUserId, + "Atlas Bakery", + "America/Montreal", + "/images/seed/atlas-bakery-logo.svg", + cancellationToken); await UpsertClientAsync( dbContext, ScopedClientId, "Luma Coffee", "Active", - "https://images.unsplash.com/photo-1511920170033-f8396924c348?auto=format&fit=crop&w=200&q=80", + "/images/seed/luma-coffee-logo.svg", "Sofia Martin", "client@socialize.local", WorkspaceId, @@ -344,10 +348,10 @@ public static class DevelopmentSeedExtensions HiddenClientId, "Atlas Bakery", "Active", - "https://images.unsplash.com/photo-1509440159596-0249088772ff?auto=format&fit=crop&w=200&q=80", + "/images/seed/atlas-bakery-logo.svg", "Nina Cole", "nina@atlasbakery.test", - WorkspaceId, + AtlasWorkspaceId, cancellationToken); await UpsertCampaignAsync( @@ -365,7 +369,7 @@ public static class DevelopmentSeedExtensions await UpsertCampaignAsync( dbContext, HiddenCampaignId, - WorkspaceId, + AtlasWorkspaceId, HiddenClientId, "Summer Retention", "Planned", @@ -375,6 +379,34 @@ public static class DevelopmentSeedExtensions "Sequence email and paid social updates together.", cancellationToken); + await UpsertChannelAsync( + dbContext, + LumaInstagramChannelId, + WorkspaceId, + "Luma Coffee Instagram", + "Instagram", + "@lumacoffee", + null, + cancellationToken); + await UpsertChannelAsync( + dbContext, + LumaTikTokChannelId, + WorkspaceId, + "Luma Coffee TikTok", + "TikTok", + "@lumacoffee", + null, + cancellationToken); + await UpsertChannelAsync( + dbContext, + AtlasInstagramChannelId, + AtlasWorkspaceId, + "Atlas Bakery Instagram", + "Instagram", + "@atlasbakery", + null, + cancellationToken); + await UpsertContentItemAsync( dbContext, ScopedContentItemId, @@ -383,7 +415,7 @@ public static class DevelopmentSeedExtensions ScopedCampaignId, "Spring launch hero video", "Fresh seasonal menu launch across Instagram and TikTok.", - "Instagram Reel, TikTok", + "Luma Coffee Instagram, Luma Coffee TikTok", "In approval", DateTimeOffset.UtcNow.AddDays(3), "v3", @@ -392,22 +424,22 @@ public static class DevelopmentSeedExtensions await UpsertContentItemAsync( dbContext, HiddenContentItemId, - WorkspaceId, + AtlasWorkspaceId, HiddenClientId, HiddenCampaignId, "Bakery loyalty carousel", "Reward regular customers with a four-card retention carousel.", - "Instagram Carousel", + "Atlas Bakery Instagram", "Draft", DateTimeOffset.UtcNow.AddDays(10), "v1", 1, cancellationToken); - await EnsureRevisionAsync(dbContext, Guid.Parse("44444444-4444-4444-4444-000000000001"), ScopedContentItemId, 1, "v1", "Spring launch hero video", "Initial draft for the seasonal menu launch.", "Instagram Reel, TikTok", "Initial concept draft.", providerUserId, DateTimeOffset.UtcNow.AddDays(-5), cancellationToken); - await EnsureRevisionAsync(dbContext, Guid.Parse("44444444-4444-4444-4444-000000000002"), ScopedContentItemId, 2, "v2", "Spring launch hero video", "Updated hook and transitions after internal review.", "Instagram Reel, TikTok", "Addressed internal pacing feedback.", providerUserId, DateTimeOffset.UtcNow.AddDays(-3), cancellationToken); - await EnsureRevisionAsync(dbContext, Guid.Parse("44444444-4444-4444-4444-000000000003"), ScopedContentItemId, 3, "v3", "Spring launch hero video", "Fresh seasonal menu launch across Instagram and TikTok.", "Instagram Reel, TikTok", "Client-facing draft after copy cleanup.", providerUserId, DateTimeOffset.UtcNow.AddDays(-1), cancellationToken); - await EnsureRevisionAsync(dbContext, Guid.Parse("44444444-4444-4444-4444-000000000004"), HiddenContentItemId, 1, "v1", "Bakery loyalty carousel", "Reward regular customers with a four-card retention carousel.", "Instagram Carousel", "First draft.", managerUserId, DateTimeOffset.UtcNow.AddDays(-2), cancellationToken); + await EnsureRevisionAsync(dbContext, Guid.Parse("44444444-4444-4444-4444-000000000001"), ScopedContentItemId, 1, "v1", "Spring launch hero video", "Initial draft for the seasonal menu launch.", "Luma Coffee Instagram, Luma Coffee TikTok", "Initial concept draft.", providerUserId, DateTimeOffset.UtcNow.AddDays(-5), cancellationToken); + await EnsureRevisionAsync(dbContext, Guid.Parse("44444444-4444-4444-4444-000000000002"), ScopedContentItemId, 2, "v2", "Spring launch hero video", "Updated hook and transitions after internal review.", "Luma Coffee Instagram, Luma Coffee TikTok", "Addressed internal pacing feedback.", providerUserId, DateTimeOffset.UtcNow.AddDays(-3), cancellationToken); + await EnsureRevisionAsync(dbContext, Guid.Parse("44444444-4444-4444-4444-000000000003"), ScopedContentItemId, 3, "v3", "Spring launch hero video", "Fresh seasonal menu launch across Instagram and TikTok.", "Luma Coffee Instagram, Luma Coffee TikTok", "Client-facing draft after copy cleanup.", providerUserId, DateTimeOffset.UtcNow.AddDays(-1), cancellationToken); + await EnsureRevisionAsync(dbContext, Guid.Parse("44444444-4444-4444-4444-000000000004"), HiddenContentItemId, 1, "v1", "Bakery loyalty carousel", "Reward regular customers with a four-card retention carousel.", "Atlas Bakery Instagram", "First draft.", managerUserId, DateTimeOffset.UtcNow.AddDays(-2), cancellationToken); Asset? asset = await dbContext.Assets.SingleOrDefaultAsync(candidate => candidate.Id == ScopedAssetId, cancellationToken); if (asset is null) @@ -535,6 +567,38 @@ public static class DevelopmentSeedExtensions await dbContext.SaveChangesAsync(cancellationToken); } + private static async Task UpsertWorkspaceAsync( + AppDbContext dbContext, + Guid id, + Guid organizationId, + Guid ownerUserId, + string name, + string timeZone, + string logoUrl, + CancellationToken cancellationToken) + { + Workspace? workspace = await dbContext.Workspaces + .SingleOrDefaultAsync(candidate => candidate.Id == id, cancellationToken); + if (workspace is null) + { + workspace = new Workspace + { + Id = id, + Name = string.Empty, + TimeZone = string.Empty, + CreatedAt = DateTimeOffset.UtcNow, + }; + dbContext.Workspaces.Add(workspace); + } + + workspace.Name = name; + workspace.OrganizationId = organizationId; + workspace.OwnerUserId = ownerUserId; + workspace.TimeZone = timeZone; + workspace.LogoUrl = logoUrl; + await dbContext.SaveChangesAsync(cancellationToken); + } + private static async Task UpsertClientAsync( AppDbContext dbContext, Guid id, @@ -604,6 +668,37 @@ public static class DevelopmentSeedExtensions await dbContext.SaveChangesAsync(cancellationToken); } + private static async Task UpsertChannelAsync( + AppDbContext dbContext, + Guid id, + Guid workspaceId, + string name, + string network, + string? handle, + string? externalUrl, + CancellationToken cancellationToken) + { + Channel? channel = await dbContext.Channels.SingleOrDefaultAsync(candidate => candidate.Id == id, cancellationToken); + if (channel is null) + { + channel = new Channel + { + Id = id, + Name = string.Empty, + Network = string.Empty, + CreatedAt = DateTimeOffset.UtcNow, + }; + dbContext.Channels.Add(channel); + } + + channel.WorkspaceId = workspaceId; + channel.Name = name; + channel.Network = network; + channel.Handle = handle; + channel.ExternalUrl = externalUrl; + await dbContext.SaveChangesAsync(cancellationToken); + } + private static async Task UpsertContentItemAsync( AppDbContext dbContext, Guid id, diff --git a/backend/src/Socialize.Api/Migrations/20260505162446_AddChannels.Designer.cs b/backend/src/Socialize.Api/Migrations/20260505162446_AddChannels.Designer.cs new file mode 100644 index 0000000..e616856 --- /dev/null +++ b/backend/src/Socialize.Api/Migrations/20260505162446_AddChannels.Designer.cs @@ -0,0 +1,1540 @@ +// +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("20260505162446_AddChannels")] + partial class AddChannels + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Approvals.Data.ApprovalDecision", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovalRequestId") + .HasColumnType("uuid"); + + b.Property("Comment") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DecidedByEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("DecidedByName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("DecidedByUserId") + .HasColumnType("uuid"); + + b.Property("Decision") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("ApprovalRequestId"); + + b.ToTable("ApprovalDecisions", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Approvals.Data.ApprovalRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessToken") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ContentItemId") + .HasColumnType("uuid"); + + b.Property("DueAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RequestedByUserId") + .HasColumnType("uuid"); + + b.Property("ReviewerEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ReviewerName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Stage") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("State") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("WorkflowInstanceId") + .HasColumnType("uuid"); + + b.Property("WorkflowStepRequiredApproverCount") + .HasColumnType("integer"); + + b.Property("WorkflowStepSortOrder") + .HasColumnType("integer"); + + b.Property("WorkflowStepTargetType") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("WorkflowStepTargetValue") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ContentItemId"); + + b.HasIndex("ReviewerEmail"); + + b.HasIndex("WorkflowInstanceId"); + + b.HasIndex("WorkspaceId"); + + b.ToTable("ApprovalRequests", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Approvals.Data.ApprovalWorkflowInstance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovalMode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ContentItemId") + .HasColumnType("uuid"); + + b.Property("StartedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("State") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ContentItemId"); + + b.HasIndex("WorkspaceId"); + + b.HasIndex("ContentItemId", "State") + .IsUnique() + .HasFilter("\"State\" = 'Pending'"); + + b.ToTable("ApprovalWorkflowInstances", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Approvals.Data.WorkspaceApprovalStepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("RequiredApproverCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TargetValue") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorkspaceId"); + + b.HasIndex("WorkspaceId", "SortOrder") + .IsUnique(); + + b.ToTable("WorkspaceApprovalStepConfigurations", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Assets.Data.Asset", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssetType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ContentItemId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CurrentRevisionNumber") + .HasColumnType("integer"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("GoogleDriveFileId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("GoogleDriveLink") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("PreviewUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("SourceType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ContentItemId"); + + b.HasIndex("WorkspaceId"); + + b.ToTable("Assets", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Assets.Data.AssetRevision", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssetId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatedByUserId") + .HasColumnType("uuid"); + + b.Property("Notes") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("PreviewUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("RevisionNumber") + .HasColumnType("integer"); + + b.Property("SourceReference") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.HasKey("Id"); + + b.HasIndex("AssetId"); + + b.HasIndex("AssetId", "RevisionNumber") + .IsUnique(); + + b.ToTable("AssetRevisions", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.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("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("IsResolved") + .HasColumnType("boolean"); + + b.Property("ParentCommentId") + .HasColumnType("uuid"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone"); + + 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.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("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("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("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OwnerUserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + 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.Workspaces.Data.Workspace", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovalMode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasDefaultValue("Required"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("LockContentAfterApproval") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("LogoUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("OwnerUserId") + .HasColumnType("uuid"); + + b.Property("SchedulePostsAutomaticallyOnApproval") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("SendAutomaticApprovalReminders") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("TimeZone") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Workspaces", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Workspaces.Data.WorkspaceInvite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("InvitedByUserId") + .HasColumnType("uuid"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorkspaceId"); + + b.HasIndex("WorkspaceId", "Email", "Status"); + + b.ToTable("WorkspaceInvites", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Socialize.Api.Modules.Identity.Data.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Socialize.Api.Modules.Identity.Data.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Socialize.Api.Modules.Identity.Data.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Socialize.Api.Modules.Identity.Data.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Socialize.Api.Modules.Identity.Data.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Socialize.Api.Modules.Identity.Data.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.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.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.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.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.Feedback.Data.FeedbackReport", b => + { + b.Navigation("ActivityEntries"); + + b.Navigation("Comments"); + + b.Navigation("Screenshot"); + + b.Navigation("Tags"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Socialize.Api/Migrations/20260505162446_AddChannels.cs b/backend/src/Socialize.Api/Migrations/20260505162446_AddChannels.cs new file mode 100644 index 0000000..ccc77f4 --- /dev/null +++ b/backend/src/Socialize.Api/Migrations/20260505162446_AddChannels.cs @@ -0,0 +1,50 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Socialize.Api.Migrations +{ + /// + public partial class AddChannels : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Channels", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + WorkspaceId = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + Network = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Handle = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + ExternalUrl = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP") + }, + constraints: table => + { + table.PrimaryKey("PK_Channels", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_Channels_WorkspaceId", + table: "Channels", + column: "WorkspaceId"); + + migrationBuilder.CreateIndex( + name: "IX_Channels_WorkspaceId_Network_Name", + table: "Channels", + columns: new[] { "WorkspaceId", "Network", "Name" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Channels"); + } + } +} diff --git a/backend/src/Socialize.Api/Migrations/AppDbContextModelSnapshot.cs b/backend/src/Socialize.Api/Migrations/AppDbContextModelSnapshot.cs index cfbf94f..494ff38 100644 --- a/backend/src/Socialize.Api/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/Socialize.Api/Migrations/AppDbContextModelSnapshot.cs @@ -491,6 +491,48 @@ namespace Socialize.Api.Migrations 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") diff --git a/backend/src/Socialize.Api/Modules/Channels/Data/Channel.cs b/backend/src/Socialize.Api/Modules/Channels/Data/Channel.cs new file mode 100644 index 0000000..9e858ee --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Channels/Data/Channel.cs @@ -0,0 +1,12 @@ +namespace Socialize.Api.Modules.Channels.Data; + +public class Channel +{ + public Guid Id { get; init; } + public Guid WorkspaceId { get; set; } + public required string Name { get; set; } + public required string Network { get; set; } + public string? Handle { get; set; } + public string? ExternalUrl { get; set; } + public DateTimeOffset CreatedAt { get; init; } +} diff --git a/backend/src/Socialize.Api/Modules/Channels/Data/ChannelModelConfiguration.cs b/backend/src/Socialize.Api/Modules/Channels/Data/ChannelModelConfiguration.cs new file mode 100644 index 0000000..131554b --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Channels/Data/ChannelModelConfiguration.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore; + +namespace Socialize.Api.Modules.Channels.Data; + +public static class ChannelModelConfiguration +{ + public static ModelBuilder ConfigureChannelsModule(this ModelBuilder modelBuilder) + { + modelBuilder.Entity(channel => + { + channel.ToTable("Channels"); + channel.HasKey(x => x.Id); + channel.Property(x => x.Name).HasMaxLength(256).IsRequired(); + channel.Property(x => x.Network).HasMaxLength(64).IsRequired(); + channel.Property(x => x.Handle).HasMaxLength(256); + channel.Property(x => x.ExternalUrl).HasMaxLength(2048); + channel.Property(x => x.CreatedAt) + .ValueGeneratedOnAdd() + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + channel.HasIndex(x => x.WorkspaceId); + channel.HasIndex(x => new { x.WorkspaceId, x.Network, x.Name }).IsUnique(); + }); + + return modelBuilder; + } +} diff --git a/backend/src/Socialize.Api/Modules/Channels/DependencyInjection.cs b/backend/src/Socialize.Api/Modules/Channels/DependencyInjection.cs new file mode 100644 index 0000000..3ee9b86 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Channels/DependencyInjection.cs @@ -0,0 +1,10 @@ +namespace Socialize.Api.Modules.Channels; + +public static class DependencyInjection +{ + public static WebApplicationBuilder AddChannelsModule( + this WebApplicationBuilder builder) + { + return builder; + } +} diff --git a/backend/src/Socialize.Api/Modules/Channels/Handlers/ChannelDtos.cs b/backend/src/Socialize.Api/Modules/Channels/Handlers/ChannelDtos.cs new file mode 100644 index 0000000..0ffc9bb --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Channels/Handlers/ChannelDtos.cs @@ -0,0 +1,10 @@ +namespace Socialize.Api.Modules.Channels.Handlers; + +public record ChannelDto( + Guid Id, + Guid WorkspaceId, + string Name, + string Network, + string? Handle, + string? ExternalUrl, + DateTimeOffset CreatedAt); diff --git a/backend/src/Socialize.Api/Modules/Channels/Handlers/CreateChannel.cs b/backend/src/Socialize.Api/Modules/Channels/Handlers/CreateChannel.cs new file mode 100644 index 0000000..fd0956f --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Channels/Handlers/CreateChannel.cs @@ -0,0 +1,115 @@ +using FastEndpoints; +using Microsoft.EntityFrameworkCore; +using Socialize.Api.Data; +using Socialize.Api.Infrastructure.Security; +using Socialize.Api.Modules.Channels.Data; + +namespace Socialize.Api.Modules.Channels.Handlers; + +public record CreateChannelRequest( + Guid WorkspaceId, + string Name, + string Network, + string? Handle, + string? ExternalUrl); + +public class CreateChannelRequestValidator + : Validator +{ + private static readonly string[] AllowedNetworks = + [ + "Instagram", + "TikTok", + "Facebook", + "LinkedIn", + "YouTube", + "X", + "Reddit", + "Website", + ]; + + public CreateChannelRequestValidator() + { + RuleFor(x => x.WorkspaceId).NotEmpty(); + RuleFor(x => x.Name).NotEmpty().MaximumLength(256); + RuleFor(x => x.Network).NotEmpty().Must(network => AllowedNetworks.Contains(network)) + .WithMessage("Selected network is invalid."); + RuleFor(x => x.Handle).MaximumLength(256); + RuleFor(x => x.ExternalUrl).MaximumLength(2048); + } +} + +public class CreateChannelHandler( + AppDbContext dbContext, + AccessScopeService accessScopeService) + : Endpoint +{ + public override void Configure() + { + Post("/api/channels"); + Options(o => o.WithTags("Channels")); + } + + public override async Task HandleAsync(CreateChannelRequest request, CancellationToken ct) + { + if (!await accessScopeService.CanManageWorkspaceAsync(User, request.WorkspaceId, ct)) + { + await SendForbiddenAsync(ct); + return; + } + + bool workspaceExists = await dbContext.Workspaces + .AnyAsync(workspace => workspace.Id == request.WorkspaceId, ct); + + if (!workspaceExists) + { + AddError(request => request.WorkspaceId, "The selected workspace does not exist."); + await SendErrorsAsync(StatusCodes.Status400BadRequest, ct); + return; + } + + string normalizedName = request.Name.Trim(); + string normalizedNetwork = request.Network.Trim(); + string? normalizedHandle = request.Handle?.Trim(); + string? normalizedExternalUrl = request.ExternalUrl?.Trim(); + + bool duplicateChannel = await dbContext.Channels + .AnyAsync( + channel => channel.WorkspaceId == request.WorkspaceId + && channel.Network == normalizedNetwork + && channel.Name == normalizedName, + ct); + + if (duplicateChannel) + { + AddError(request => request.Name, "A channel with this name already exists for the selected network."); + await SendErrorsAsync(StatusCodes.Status409Conflict, ct); + return; + } + + Channel channel = new() + { + Id = Guid.NewGuid(), + WorkspaceId = request.WorkspaceId, + Name = normalizedName, + Network = normalizedNetwork, + Handle = string.IsNullOrWhiteSpace(normalizedHandle) ? null : normalizedHandle, + ExternalUrl = string.IsNullOrWhiteSpace(normalizedExternalUrl) ? null : normalizedExternalUrl, + CreatedAt = DateTimeOffset.UtcNow, + }; + + dbContext.Channels.Add(channel); + await dbContext.SaveChangesAsync(ct); + + ChannelDto dto = new( + channel.Id, + channel.WorkspaceId, + channel.Name, + channel.Network, + channel.Handle, + channel.ExternalUrl, + channel.CreatedAt); + + await SendAsync(dto, StatusCodes.Status201Created, ct); + } +} diff --git a/backend/src/Socialize.Api/Modules/Channels/Handlers/GetChannels.cs b/backend/src/Socialize.Api/Modules/Channels/Handlers/GetChannels.cs new file mode 100644 index 0000000..8c34f4a --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Channels/Handlers/GetChannels.cs @@ -0,0 +1,52 @@ +using FastEndpoints; +using Microsoft.EntityFrameworkCore; +using Socialize.Api.Data; +using Socialize.Api.Infrastructure.Security; +using Socialize.Api.Modules.Channels.Data; + +namespace Socialize.Api.Modules.Channels.Handlers; + +public record GetChannelsRequest(Guid? WorkspaceId); + +public class GetChannelsHandler( + AppDbContext dbContext, + AccessScopeService accessScopeService) + : Endpoint> +{ + public override void Configure() + { + Get("/api/channels"); + Options(o => o.WithTags("Channels")); + } + + public override async Task HandleAsync(GetChannelsRequest request, CancellationToken ct) + { + IQueryable query = dbContext.Channels.AsQueryable(); + + if (!accessScopeService.IsManager(User)) + { + IReadOnlyCollection workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct); + query = query.Where(channel => workspaceScopeIds.Contains(channel.WorkspaceId)); + } + + if (request.WorkspaceId.HasValue) + { + query = query.Where(channel => channel.WorkspaceId == request.WorkspaceId.Value); + } + + List channels = await query + .OrderBy(channel => channel.Network) + .ThenBy(channel => channel.Name) + .Select(channel => new ChannelDto( + channel.Id, + channel.WorkspaceId, + channel.Name, + channel.Network, + channel.Handle, + channel.ExternalUrl, + channel.CreatedAt)) + .ToListAsync(ct); + + await SendOkAsync(channels, ct); + } +} diff --git a/backend/src/Socialize.Api/Program.cs b/backend/src/Socialize.Api/Program.cs index cbcffee..f91d128 100644 --- a/backend/src/Socialize.Api/Program.cs +++ b/backend/src/Socialize.Api/Program.cs @@ -10,6 +10,7 @@ using Socialize.Api.Infrastructure; using Socialize.Api.Infrastructure.Development; using Socialize.Api.Modules.Approvals; using Socialize.Api.Modules.Assets; +using Socialize.Api.Modules.Channels; using Socialize.Api.Modules.Clients; using Socialize.Api.Modules.Comments; using Socialize.Api.Modules.ContentItems; @@ -65,6 +66,7 @@ builder.AddInfrastructureModule(); builder.AddIdentityModule(); builder.AddOrganizationsModule(); builder.AddWorkspaceModule(); +builder.AddChannelsModule(); builder.AddClientsModule(); builder.AddCampaignsModule(); builder.AddContentItemsModule(); diff --git a/docs/FEATURES/channels.md b/docs/FEATURES/channels.md new file mode 100644 index 0000000..ae7bdb2 --- /dev/null +++ b/docs/FEATURES/channels.md @@ -0,0 +1,43 @@ +# Channels + +Channels are configured social destinations inside a workspace. They represent the account, handle, page, feed, newsletter, or other publication destination where content will eventually be handed off for publishing. + +Channels are workspace-owned data. Organization-owned connectors may provide credentials for external systems, but the workspace owns which destinations are available for content planning. + +## Model + +A channel has: + +- `id` +- `workspaceId` +- `name` +- `network` +- optional `handle` +- optional `externalUrl` +- `createdAt` + +`network` is a controlled string matching the frontend channel network options: + +- `Instagram` +- `TikTok` +- `Facebook` +- `LinkedIn` +- `YouTube` +- `X` +- `Reddit` +- `Website` + +Channel names must be unique inside a workspace for the same network. + +## Behavior + +- Authenticated users with workspace access can list channels for their active workspace. +- Workspace managers can create channels. +- Content planning uses configured channels as selectable destinations. +- Development seed data should create real workspace channels instead of relying on content target labels as fake channels. + +## Not In Scope + +- External connector credentials. +- Publishing directly to social networks. +- Channel deletion, archiving, or editing. diff --git a/docs/TASKS/content/002-content-detail-back-navigation.md b/docs/TASKS/content/002-content-detail-back-navigation.md new file mode 100644 index 0000000..b08a0f4 --- /dev/null +++ b/docs/TASKS/content/002-content-detail-back-navigation.md @@ -0,0 +1,23 @@ +# Task: Add content detail back navigation + +## Goal + +Make it easy to return to the Content calendar or upcoming list after opening a content item detail page. + +## Scope + +- Add a visible back control to `ContentItemDetailView`. +- Preserve the originating Content page view state when navigating from calendar or upcoming entries. +- Keep the change frontend-only. + +## Relevant Files + +- `frontend/src/features/content/views/ContentItemsView.vue` +- `frontend/src/features/content/views/ContentItemDetailView.vue` + +## Validation + +```bash +cd frontend +npm run build +``` diff --git a/docs/TASKS/content/003-real-workspace-channels.md b/docs/TASKS/content/003-real-workspace-channels.md new file mode 100644 index 0000000..b2d33d4 --- /dev/null +++ b/docs/TASKS/content/003-real-workspace-channels.md @@ -0,0 +1,34 @@ +# Task: Add real workspace channels + +## Feature + +`docs/FEATURES/channels.md` + +## Goal + +Replace frontend-derived fake channels with real workspace-owned channel records served by the backend API. + +## Scope + +- Add a backend `Channels` module with a `Channel` table. +- Add list and create endpoints for workspace channels. +- Seed development channels and align seeded content publication targets to those configured channel names. +- Update the frontend channels store to load and create channels through the API. +- Keep the Channels page UI shape intact. + +## Relevant Files + +- `backend/src/Socialize.Api/Data/AppDbContext.cs` +- `backend/src/Socialize.Api/Modules/Channels/` +- `backend/src/Socialize.Api/Infrastructure/Development/DevelopmentSeedExtensions.cs` +- `frontend/src/features/channels/stores/channelsStore.js` +- `docs/FEATURES/channels.md` + +## Validation + +```bash +dotnet build backend/Socialize.slnx +dotnet test backend/Socialize.slnx +cd frontend +npm run build +``` diff --git a/frontend/public/images/seed/atlas-bakery-logo.svg b/frontend/public/images/seed/atlas-bakery-logo.svg new file mode 100644 index 0000000..efd8ee9 --- /dev/null +++ b/frontend/public/images/seed/atlas-bakery-logo.svg @@ -0,0 +1,11 @@ + + Atlas Bakery logo + A bakery wheat and loaf mark in rose and golden tones. + + + + + + + + diff --git a/frontend/public/images/seed/luma-coffee-logo.svg b/frontend/public/images/seed/luma-coffee-logo.svg new file mode 100644 index 0000000..9039b42 --- /dev/null +++ b/frontend/public/images/seed/luma-coffee-logo.svg @@ -0,0 +1,10 @@ + + Luma Coffee logo + A warm coffee cup monogram inside a cream roundel. + + + + + + + diff --git a/frontend/public/images/seed/northstar-agency-logo.svg b/frontend/public/images/seed/northstar-agency-logo.svg new file mode 100644 index 0000000..5b21d21 --- /dev/null +++ b/frontend/public/images/seed/northstar-agency-logo.svg @@ -0,0 +1,9 @@ + + Northstar Agency logo + A navy square mark with a teal north star and orange accent. + + + + + + diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index d1ad288..7bd9234 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -788,6 +788,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/channels": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["SocializeApiModulesChannelsHandlersGetChannelsHandler"]; + put?: never; + post: operations["SocializeApiModulesChannelsHandlersCreateChannelHandler"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/campaigns": { parameters: { query?: never; @@ -1411,6 +1427,27 @@ export interface components { primaryContactEmail?: string | null; primaryContactPortraitUrl?: string | null; }; + SocializeApiModulesChannelsHandlersChannelDto: { + /** Format: guid */ + id?: string; + /** Format: guid */ + workspaceId?: string; + name?: string; + network?: string; + handle?: string | null; + externalUrl?: string | null; + /** Format: date-time */ + createdAt?: string; + }; + SocializeApiModulesChannelsHandlersCreateChannelRequest: { + /** Format: guid */ + workspaceId: string; + name: string; + network: string; + handle?: string | null; + externalUrl?: string | null; + }; + SocializeApiModulesChannelsHandlersGetChannelsRequest: Record; SocializeApiModulesCampaignsHandlersCampaignDto: { /** Format: guid */ id?: string; @@ -3426,6 +3463,75 @@ export interface operations { }; }; }; + SocializeApiModulesChannelsHandlersGetChannelsHandler: { + parameters: { + query?: { + workspaceId?: string | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SocializeApiModulesChannelsHandlersChannelDto"][]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + SocializeApiModulesChannelsHandlersCreateChannelHandler: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SocializeApiModulesChannelsHandlersCreateChannelRequest"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SocializeApiModulesChannelsHandlersChannelDto"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["FastEndpointsErrorResponse"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; SocializeApiModulesCampaignsHandlersGetCampaignsHandler: { parameters: { query?: { diff --git a/frontend/src/features/channels/stores/channelsStore.js b/frontend/src/features/channels/stores/channelsStore.js index 9fc9751..205101a 100644 --- a/frontend/src/features/channels/stores/channelsStore.js +++ b/frontend/src/features/channels/stores/channelsStore.js @@ -1,52 +1,19 @@ -import { computed } from 'vue'; +import { ref, watch } from 'vue'; import { defineStore } from 'pinia'; -import { useSessionStorage } from '@vueuse/core'; +import { useAuthStore } from '@/features/auth/stores/authStore.js'; import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js'; -import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js'; +import { useClient } from '@/plugins/api.js'; export const useChannelsStore = defineStore('channels', () => { + const authStore = useAuthStore(); const workspaceStore = useWorkspaceStore(); - const contentItemsStore = useContentItemsStore(); - const customChannelsByWorkspace = useSessionStorage('workspace-custom-channels', {}, { - serializer: { - read: value => (value ? JSON.parse(value) : {}), - write: value => JSON.stringify(value ?? {}), - }, - }); + const client = useClient(); - const channels = computed(() => { - const currentWorkspaceId = workspaceStore.activeWorkspaceId; - - if (!currentWorkspaceId) { - return []; - } - - const derivedChannels = new Map(); - const customChannels = customChannelsByWorkspace.value[currentWorkspaceId] ?? []; - - for (const item of contentItemsStore.items) { - for (const name of parseTargets(item.publicationTargets)) { - const key = normalizeChannelKey(name); - const existing = derivedChannels.get(key) ?? { - id: key, - name, - network: null, - source: 'derived', - }; - - derivedChannels.set(key, existing); - } - } - - for (const channel of customChannels) { - derivedChannels.set(channel.id, { - ...channel, - source: 'custom', - }); - } - - return [...derivedChannels.values()].sort((left, right) => left.name.localeCompare(right.name)); - }); + const channels = ref([]); + const isLoading = ref(false); + const isCreating = ref(false); + const error = ref(null); + const loadedWorkspaceId = ref(null); const availableNetworks = [ 'Instagram', @@ -59,64 +26,102 @@ export const useChannelsStore = defineStore('channels', () => { 'Website', ]; - function createChannel(payload) { + async function fetchChannels({ force = false } = {}) { const currentWorkspaceId = workspaceStore.activeWorkspaceId; - if (!currentWorkspaceId) { + if (!authStore.isAuthenticated || !currentWorkspaceId) { + channels.value = []; + error.value = null; + loadedWorkspaceId.value = null; + return; + } + + if (!force && loadedWorkspaceId.value === currentWorkspaceId) { + return; + } + + isLoading.value = true; + error.value = null; + + try { + const response = await client.get('/api/channels', { + params: { + workspaceId: currentWorkspaceId, + }, + }); + + channels.value = response.data ?? []; + loadedWorkspaceId.value = currentWorkspaceId; + } catch (fetchError) { + console.error('Failed to fetch channels:', fetchError); + channels.value = []; + loadedWorkspaceId.value = null; + error.value = 'Failed to load channels.'; + } finally { + isLoading.value = false; + } + } + + async function createChannel(payload) { + const currentWorkspaceId = workspaceStore.activeWorkspaceId; + + if (!authStore.isAuthenticated || !currentWorkspaceId) { throw new Error('An active workspace is required to create a channel.'); } - const normalizedName = payload.name.trim(); - const normalizedNetwork = payload.network.trim(); - - if (!normalizedName) { - throw new Error('Channel name is required.'); + if (isCreating.value) { + throw new Error('A channel creation request is already in progress.'); } - if (!normalizedNetwork) { - throw new Error('Network is required.'); - } + isCreating.value = true; + error.value = null; - if (!availableNetworks.includes(normalizedNetwork)) { - throw new Error('Selected network is invalid.'); - } + try { + const response = await client.post('/api/channels', { + ...payload, + workspaceId: currentWorkspaceId, + }); - const existing = channels.value.some(channel => - channel.name.toLowerCase() === normalizedName.toLowerCase() - && (channel.network ?? '').toLowerCase() === normalizedNetwork.toLowerCase() - ); - if (existing) { - throw new Error('A channel with this name already exists for the selected network.'); - } + if (response.data) { + channels.value = [...channels.value, response.data] + .sort((left, right) => left.name.localeCompare(right.name)); + } - const next = customChannelsByWorkspace.value[currentWorkspaceId] ?? []; - customChannelsByWorkspace.value = { - ...customChannelsByWorkspace.value, - [currentWorkspaceId]: [ - ...next, - { - id: normalizeChannelKey(`${normalizedNetwork}-${normalizedName}`), - name: normalizedName, - network: normalizedNetwork, - }, - ], - }; + return response.data; + } catch (createError) { + console.error('Failed to create channel:', createError); + const message = createError.response?.data?.errors?.[0]?.reason + ?? createError.response?.data?.message + ?? 'Failed to create channel.'; + error.value = message; + throw new Error(message); + } finally { + isCreating.value = false; + } } - function parseTargets(value) { - return (value ?? '') - .split(/[,\n]+/) - .map(target => target.trim()) - .filter(Boolean); - } + watch( + () => [authStore.isAuthenticated, workspaceStore.activeWorkspaceId], + async ([isAuthenticated, workspaceId]) => { + if (!isAuthenticated || !workspaceId) { + channels.value = []; + error.value = null; + loadedWorkspaceId.value = null; + return; + } - function normalizeChannelKey(value) { - return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); - } + await fetchChannels(); + }, + { immediate: true } + ); return { availableNetworks, channels, + isLoading, + isCreating, + error, + fetchChannels, createChannel, }; }); diff --git a/frontend/src/features/channels/views/ChannelsView.vue b/frontend/src/features/channels/views/ChannelsView.vue index 27a0aa7..ebe8c07 100644 --- a/frontend/src/features/channels/views/ChannelsView.vue +++ b/frontend/src/features/channels/views/ChannelsView.vue @@ -1,5 +1,5 @@