From b66c10b681d0a27ff26a9b10ad9029f27952b65e Mon Sep 17 00:00:00 2001 From: Jonathan Bourdon Date: Tue, 5 May 2026 15:25:53 -0400 Subject: [PATCH] Add calendar integrations and collaboration updates --- .../src/Socialize.Api/Data/AppDbContext.cs | 7 + .../BlobStorage/Contracts/ContainerNames.cs | 1 + .../Development/DevelopmentSeedExtensions.cs | 2 - .../20260505013232_Initial.Designer.cs | 1498 ----------------- .../Migrations/20260505162446_AddChannels.cs | 50 - ....cs => 20260505192305_Initial.Designer.cs} | 399 ++++- ...2_Initial.cs => 20260505192305_Initial.cs} | 259 ++- .../Migrations/AppDbContextModelSnapshot.cs | 395 ++++- .../Handlers/SubmitApprovalDecision.cs | 21 + .../Assets/Handlers/CreateAssetRevision.cs | 22 + .../Assets/Handlers/CreateGoogleDriveAsset.cs | 22 + .../Data/CalendarCatalogEntry.cs | 18 + .../Data/CalendarCatalogSeed.cs | 53 + .../Data/CalendarEvent.cs | 24 + .../Data/CalendarSource.cs | 22 + .../Data/CalendarSourceModelConfiguration.cs | 95 ++ .../Data/UserCalendarExportFeed.cs | 12 + .../DependencyInjection.cs | 15 + .../Handlers/CalendarSourceDtos.cs | 112 ++ .../Handlers/CreateCalendarSource.cs | 132 ++ .../Handlers/DeleteCalendarSource.cs | 64 + .../Handlers/ListCalendarCatalog.cs | 115 ++ .../Handlers/ListCalendarEvents.cs | 133 ++ .../Handlers/ListCalendarSources.cs | 77 + .../Handlers/RefreshCalendarSource.cs | 65 + .../Handlers/UpdateCalendarSource.cs | 91 + .../Handlers/UserCalendarExportFeed.cs | 224 +++ .../Services/CalendarExportFeedBuilder.cs | 80 + .../Services/CalendarExportFeedService.cs | 173 ++ .../CalendarExportFeedTokenService.cs | 27 + .../CalendarImportBackgroundService.cs | 34 + .../Services/CalendarImportSyncService.cs | 313 ++++ .../Services/CalendarSourceConstants.cs | 14 + .../Services/CalendarSourceRules.cs | 51 + .../Services/IcsCalendarParser.cs | 414 +++++ .../Modules/Comments/Data/Comment.cs | 2 - .../Comments/Handlers/CreateComment.cs | 23 +- .../Modules/Comments/Handlers/GetComments.cs | 8 +- .../Comments/Handlers/ResolveComment.cs | 89 - .../Contracts/IContentItemActivityWriter.cs | 17 + .../Data/ContentItemActivityEntry.cs | 16 + .../Data/ContentItemModelConfiguration.cs | 17 + .../ContentItems/DependencyInjection.cs | 5 +- .../Handlers/CreateContentItem.cs | 23 + .../Handlers/CreateContentItemRevision.cs | 65 +- .../Handlers/GetContentItemActivity.cs | 72 + .../Handlers/UpdateContentItemStatus.cs | 24 + .../Services/ContentItemActivityWriter.cs | 31 + .../Organizations/Data/Organization.cs | 1 + .../Data/OrganizationModelConfiguration.cs | 1 + .../Handlers/AddOrganizationMember.cs | 129 ++ .../Handlers/ChangeOrganizationLogo.cs | 75 + .../Organizations/Handlers/GetOrganization.cs | 55 +- .../Handlers/OrganizationDtos.cs | 16 +- .../Handlers/UpdateOrganization.cs | 66 + backend/src/Socialize.Api/Program.cs | 2 + .../CalendarExportFeedTests.cs | 63 + .../CalendarImportSyncServiceTests.cs | 16 + .../CalendarSourceRulesTests.cs | 87 + .../IcsCalendarParserTests.cs | 132 ++ docs/FEATURES/calendar-integrations.md | 10 +- .../003-content-calendar-ui-integration.md | 2 +- ...-content-production-collaboration-panel.md | 46 + .../005-scope-content-editor-channels.md | 32 + .../content/006-content-activity-endpoint.md | 40 + .../006-organization-settings-editing.md | 46 + frontend/src/api/schema.d.ts | 343 +++- .../stores/calendarIntegrationsStore.js | 182 ++ .../content/stores/contentItemDetailStore.js | 47 +- .../content/views/ContentItemDetailView.vue | 876 +++++++++- .../content/views/ContentItemsView.vue | 842 ++++++++- .../organizations/stores/organizationStore.js | 143 +- .../views/OrganizationSettingsView.vue | 531 +++++- .../user-profile/stores/userProfileStore.js | 64 +- .../user-profile/views/UserSettingsView.vue | 173 +- .../views/WorkspaceSettingsView.vue | 249 ++- .../src/layouts/main/WorkspaceSelector.vue | 18 +- frontend/src/locales/en.json | 92 +- frontend/src/locales/fr.json | 92 +- scripts/dev-backend.sh | 2 +- scripts/recycle-database.sh | 23 +- shared/openapi/openapi.json | 546 +++++- 82 files changed, 8420 insertions(+), 2048 deletions(-) delete mode 100644 backend/src/Socialize.Api/Migrations/20260505013232_Initial.Designer.cs delete mode 100644 backend/src/Socialize.Api/Migrations/20260505162446_AddChannels.cs rename backend/src/Socialize.Api/Migrations/{20260505162446_AddChannels.Designer.cs => 20260505192305_Initial.Designer.cs} (78%) rename backend/src/Socialize.Api/Migrations/{20260505013232_Initial.cs => 20260505192305_Initial.cs} (77%) create mode 100644 backend/src/Socialize.Api/Modules/CalendarIntegrations/Data/CalendarCatalogEntry.cs create mode 100644 backend/src/Socialize.Api/Modules/CalendarIntegrations/Data/CalendarCatalogSeed.cs create mode 100644 backend/src/Socialize.Api/Modules/CalendarIntegrations/Data/CalendarEvent.cs create mode 100644 backend/src/Socialize.Api/Modules/CalendarIntegrations/Data/CalendarSource.cs create mode 100644 backend/src/Socialize.Api/Modules/CalendarIntegrations/Data/CalendarSourceModelConfiguration.cs create mode 100644 backend/src/Socialize.Api/Modules/CalendarIntegrations/Data/UserCalendarExportFeed.cs create mode 100644 backend/src/Socialize.Api/Modules/CalendarIntegrations/DependencyInjection.cs create mode 100644 backend/src/Socialize.Api/Modules/CalendarIntegrations/Handlers/CalendarSourceDtos.cs create mode 100644 backend/src/Socialize.Api/Modules/CalendarIntegrations/Handlers/CreateCalendarSource.cs create mode 100644 backend/src/Socialize.Api/Modules/CalendarIntegrations/Handlers/DeleteCalendarSource.cs create mode 100644 backend/src/Socialize.Api/Modules/CalendarIntegrations/Handlers/ListCalendarCatalog.cs create mode 100644 backend/src/Socialize.Api/Modules/CalendarIntegrations/Handlers/ListCalendarEvents.cs create mode 100644 backend/src/Socialize.Api/Modules/CalendarIntegrations/Handlers/ListCalendarSources.cs create mode 100644 backend/src/Socialize.Api/Modules/CalendarIntegrations/Handlers/RefreshCalendarSource.cs create mode 100644 backend/src/Socialize.Api/Modules/CalendarIntegrations/Handlers/UpdateCalendarSource.cs create mode 100644 backend/src/Socialize.Api/Modules/CalendarIntegrations/Handlers/UserCalendarExportFeed.cs create mode 100644 backend/src/Socialize.Api/Modules/CalendarIntegrations/Services/CalendarExportFeedBuilder.cs create mode 100644 backend/src/Socialize.Api/Modules/CalendarIntegrations/Services/CalendarExportFeedService.cs create mode 100644 backend/src/Socialize.Api/Modules/CalendarIntegrations/Services/CalendarExportFeedTokenService.cs create mode 100644 backend/src/Socialize.Api/Modules/CalendarIntegrations/Services/CalendarImportBackgroundService.cs create mode 100644 backend/src/Socialize.Api/Modules/CalendarIntegrations/Services/CalendarImportSyncService.cs create mode 100644 backend/src/Socialize.Api/Modules/CalendarIntegrations/Services/CalendarSourceConstants.cs create mode 100644 backend/src/Socialize.Api/Modules/CalendarIntegrations/Services/CalendarSourceRules.cs create mode 100644 backend/src/Socialize.Api/Modules/CalendarIntegrations/Services/IcsCalendarParser.cs delete mode 100644 backend/src/Socialize.Api/Modules/Comments/Handlers/ResolveComment.cs create mode 100644 backend/src/Socialize.Api/Modules/ContentItems/Contracts/IContentItemActivityWriter.cs create mode 100644 backend/src/Socialize.Api/Modules/ContentItems/Data/ContentItemActivityEntry.cs create mode 100644 backend/src/Socialize.Api/Modules/ContentItems/Handlers/GetContentItemActivity.cs create mode 100644 backend/src/Socialize.Api/Modules/ContentItems/Services/ContentItemActivityWriter.cs create mode 100644 backend/src/Socialize.Api/Modules/Organizations/Handlers/AddOrganizationMember.cs create mode 100644 backend/src/Socialize.Api/Modules/Organizations/Handlers/ChangeOrganizationLogo.cs create mode 100644 backend/src/Socialize.Api/Modules/Organizations/Handlers/UpdateOrganization.cs create mode 100644 backend/tests/Socialize.Tests/CalendarIntegrations/CalendarExportFeedTests.cs create mode 100644 backend/tests/Socialize.Tests/CalendarIntegrations/CalendarImportSyncServiceTests.cs create mode 100644 backend/tests/Socialize.Tests/CalendarIntegrations/CalendarSourceRulesTests.cs create mode 100644 backend/tests/Socialize.Tests/CalendarIntegrations/IcsCalendarParserTests.cs create mode 100644 docs/TASKS/content/004-content-production-collaboration-panel.md create mode 100644 docs/TASKS/content/005-scope-content-editor-channels.md create mode 100644 docs/TASKS/content/006-content-activity-endpoint.md create mode 100644 docs/TASKS/organizations/006-organization-settings-editing.md create mode 100644 frontend/src/features/content/stores/calendarIntegrationsStore.js diff --git a/backend/src/Socialize.Api/Data/AppDbContext.cs b/backend/src/Socialize.Api/Data/AppDbContext.cs index 4193579..351fc16 100644 --- a/backend/src/Socialize.Api/Data/AppDbContext.cs +++ b/backend/src/Socialize.Api/Data/AppDbContext.cs @@ -10,6 +10,7 @@ using Socialize.Api.Modules.Feedback.Data; using Socialize.Api.Modules.Identity.Data; using Socialize.Api.Modules.Notifications.Data; using Socialize.Api.Modules.Campaigns.Data; +using Socialize.Api.Modules.CalendarIntegrations.Data; using Socialize.Api.Modules.Organizations.Data; using Socialize.Api.Modules.Workspaces.Data; @@ -28,6 +29,7 @@ public class AppDbContext( public DbSet Campaigns => Set(); public DbSet ContentItems => Set(); public DbSet ContentItemRevisions => Set(); + public DbSet ContentItemActivityEntries => Set(); public DbSet Assets => Set(); public DbSet AssetRevisions => Set(); public DbSet Comments => Set(); @@ -41,6 +43,10 @@ public class AppDbContext( public DbSet FeedbackScreenshots => Set(); public DbSet FeedbackComments => Set(); public DbSet FeedbackActivityEntries => Set(); + public DbSet CalendarSources => Set(); + public DbSet CalendarCatalogEntries => Set(); + public DbSet CalendarEvents => Set(); + public DbSet UserCalendarExportFeeds => Set(); protected override void OnModelCreating(ModelBuilder builder) { @@ -57,5 +63,6 @@ public class AppDbContext( builder.ConfigureApprovalsModule(); builder.ConfigureNotificationsModule(); builder.ConfigureFeedbackModule(); + builder.ConfigureCalendarIntegrationsModule(); } } diff --git a/backend/src/Socialize.Api/Infrastructure/BlobStorage/Contracts/ContainerNames.cs b/backend/src/Socialize.Api/Infrastructure/BlobStorage/Contracts/ContainerNames.cs index 177cc9c..9d4d7b0 100644 --- a/backend/src/Socialize.Api/Infrastructure/BlobStorage/Contracts/ContainerNames.cs +++ b/backend/src/Socialize.Api/Infrastructure/BlobStorage/Contracts/ContainerNames.cs @@ -4,6 +4,7 @@ internal static class ContainerNames { public const string Users = "users"; public const string Clients = "clients"; + public const string Organizations = "organizations"; public const string Workspaces = "workspaces"; public const string Creators = "creators"; public const string Feedback = "feedback"; diff --git a/backend/src/Socialize.Api/Infrastructure/Development/DevelopmentSeedExtensions.cs b/backend/src/Socialize.Api/Infrastructure/Development/DevelopmentSeedExtensions.cs index 1440bfc..044fa14 100644 --- a/backend/src/Socialize.Api/Infrastructure/Development/DevelopmentSeedExtensions.cs +++ b/backend/src/Socialize.Api/Infrastructure/Development/DevelopmentSeedExtensions.cs @@ -487,8 +487,6 @@ public static class DevelopmentSeedExtensions comment.AuthorDisplayName = "Sofia Martin"; comment.AuthorEmail = "client@socialize.local"; comment.Body = "Please tighten the opening three seconds and make the launch CTA more explicit."; - comment.IsResolved = false; - comment.ResolvedAt = null; await dbContext.SaveChangesAsync(cancellationToken); ApprovalRequest? approvalRequest = await dbContext.ApprovalRequests.SingleOrDefaultAsync(candidate => candidate.Id == ScopedApprovalRequestId, cancellationToken); diff --git a/backend/src/Socialize.Api/Migrations/20260505013232_Initial.Designer.cs b/backend/src/Socialize.Api/Migrations/20260505013232_Initial.Designer.cs deleted file mode 100644 index cf42168..0000000 --- a/backend/src/Socialize.Api/Migrations/20260505013232_Initial.Designer.cs +++ /dev/null @@ -1,1498 +0,0 @@ -// -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("20260505013232_Initial")] - partial class Initial - { - /// - 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.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 deleted file mode 100644 index ccc77f4..0000000 --- a/backend/src/Socialize.Api/Migrations/20260505162446_AddChannels.cs +++ /dev/null @@ -1,50 +0,0 @@ -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/20260505162446_AddChannels.Designer.cs b/backend/src/Socialize.Api/Migrations/20260505192305_Initial.Designer.cs similarity index 78% rename from backend/src/Socialize.Api/Migrations/20260505162446_AddChannels.Designer.cs rename to backend/src/Socialize.Api/Migrations/20260505192305_Initial.Designer.cs index e616856..7134872 100644 --- a/backend/src/Socialize.Api/Migrations/20260505162446_AddChannels.Designer.cs +++ b/backend/src/Socialize.Api/Migrations/20260505192305_Initial.Designer.cs @@ -12,8 +12,8 @@ using Socialize.Api.Data; namespace Socialize.Api.Migrations { [DbContext(typeof(AppDbContext))] - [Migration("20260505162446_AddChannels")] - partial class AddChannels + [Migration("20260505192305_Initial")] + partial class Initial { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -441,6 +441,326 @@ namespace Socialize.Api.Migrations 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") @@ -618,15 +938,9 @@ namespace Socialize.Api.Migrations .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"); @@ -707,6 +1021,62 @@ namespace Socialize.Api.Migrations 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") @@ -1259,6 +1629,10 @@ namespace Socialize.Api.Migrations .HasColumnType("timestamp with time zone") .HasDefaultValueSql("CURRENT_TIMESTAMP"); + b.Property("LogoUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + b.Property("Name") .IsRequired() .HasMaxLength(256) @@ -1462,6 +1836,15 @@ namespace Socialize.Api.Migrations .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.Feedback.Data.FeedbackActivityEntry", b => { b.HasOne("Socialize.Api.Modules.Feedback.Data.FeedbackReport", "FeedbackReport") diff --git a/backend/src/Socialize.Api/Migrations/20260505013232_Initial.cs b/backend/src/Socialize.Api/Migrations/20260505192305_Initial.cs similarity index 77% rename from backend/src/Socialize.Api/Migrations/20260505013232_Initial.cs rename to backend/src/Socialize.Api/Migrations/20260505192305_Initial.cs index afca074..790ae48 100644 --- a/backend/src/Socialize.Api/Migrations/20260505013232_Initial.cs +++ b/backend/src/Socialize.Api/Migrations/20260505192305_Initial.cs @@ -4,6 +4,8 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; #nullable disable +#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional + namespace Socialize.Api.Migrations { /// @@ -162,6 +164,56 @@ namespace Socialize.Api.Migrations table.PrimaryKey("PK_Assets", x => x.Id); }); + migrationBuilder.CreateTable( + name: "CalendarCatalogEntries", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Title = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + Description = table.Column(type: "character varying(1024)", maxLength: 1024, nullable: false), + Country = table.Column(type: "character varying(2)", maxLength: 2, nullable: true), + Region = table.Column(type: "character varying(128)", maxLength: 128, nullable: true), + Language = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + Category = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + CultureOrReligion = table.Column(type: "character varying(128)", maxLength: 128, nullable: true), + ProviderName = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + SourceUrl = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: false), + TrustLevel = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + DefaultColor = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP") + }, + constraints: table => + { + table.PrimaryKey("PK_CalendarCatalogEntries", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "CalendarSources", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Scope = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + OrganizationId = table.Column(type: "uuid", nullable: true), + WorkspaceId = table.Column(type: "uuid", nullable: true), + UserId = table.Column(type: "uuid", nullable: true), + SourceUrl = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: true), + CatalogSourceReference = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + DisplayTitle = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + Color = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + Category = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + IsEnabled = table.Column(type: "boolean", nullable: false), + InheritanceMode = table.Column(type: "character varying(32)", maxLength: 32, nullable: true), + LastSuccessfulSyncAt = table.Column(type: "timestamp with time zone", nullable: true), + LastAttemptedSyncAt = table.Column(type: "timestamp with time zone", nullable: true), + LastSyncError = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP") + }, + constraints: table => + { + table.PrimaryKey("PK_CalendarSources", x => x.Id); + }); + migrationBuilder.CreateTable( name: "Campaigns", columns: table => new @@ -182,6 +234,23 @@ namespace Socialize.Api.Migrations table.PrimaryKey("PK_Campaigns", x => x.Id); }); + 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.CreateTable( name: "Clients", columns: table => new @@ -213,15 +282,34 @@ namespace Socialize.Api.Migrations AuthorDisplayName = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), AuthorEmail = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), Body = table.Column(type: "character varying(4000)", maxLength: 4000, nullable: false), - IsResolved = table.Column(type: "boolean", nullable: false), - CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), - ResolvedAt = table.Column(type: "timestamp with time zone", nullable: true) + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP") }, constraints: table => { table.PrimaryKey("PK_Comments", x => x.Id); }); + migrationBuilder.CreateTable( + name: "ContentItemActivityEntries", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + WorkspaceId = table.Column(type: "uuid", nullable: false), + ContentItemId = table.Column(type: "uuid", nullable: false), + EventType = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + EntityType = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + EntityId = table.Column(type: "uuid", nullable: false), + Summary = table.Column(type: "character varying(1024)", maxLength: 1024, nullable: false), + ActorUserId = table.Column(type: "uuid", nullable: true), + ActorEmail = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + MetadataJson = table.Column(type: "jsonb", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP") + }, + constraints: table => + { + table.PrimaryKey("PK_ContentItemActivityEntries", x => x.Id); + }); + migrationBuilder.CreateTable( name: "ContentItemRevisions", columns: table => new @@ -329,6 +417,7 @@ namespace Socialize.Api.Migrations { Id = table.Column(type: "uuid", nullable: false), Name = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + LogoUrl = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: true), OwnerUserId = table.Column(type: "uuid", nullable: false), CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP") }, @@ -337,6 +426,23 @@ namespace Socialize.Api.Migrations table.PrimaryKey("PK_Organizations", x => x.Id); }); + migrationBuilder.CreateTable( + name: "UserCalendarExportFeeds", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + Token = table.Column(type: "character varying(96)", maxLength: 96, nullable: true), + TokenHash = table.Column(type: "character varying(64)", maxLength: 64, nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), + RevokedAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_UserCalendarExportFeeds", x => x.Id); + }); + migrationBuilder.CreateTable( name: "WorkspaceApprovalStepConfigurations", columns: table => new @@ -478,6 +584,41 @@ namespace Socialize.Api.Migrations onDelete: ReferentialAction.Cascade); }); + migrationBuilder.CreateTable( + name: "CalendarEvents", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + CalendarSourceId = table.Column(type: "uuid", nullable: false), + SourceEventUid = table.Column(type: "character varying(512)", maxLength: 512, nullable: false), + Title = table.Column(type: "character varying(512)", maxLength: 512, nullable: false), + Description = table.Column(type: "character varying(4000)", maxLength: 4000, nullable: true), + IsAllDay = table.Column(type: "boolean", nullable: false), + IsFloatingTime = table.Column(type: "boolean", nullable: false), + StartDate = table.Column(type: "date", nullable: false), + EndDate = table.Column(type: "date", nullable: false), + StartLocalDateTime = table.Column(type: "timestamp with time zone", nullable: true), + EndLocalDateTime = table.Column(type: "timestamp with time zone", nullable: true), + StartUtc = table.Column(type: "timestamp with time zone", nullable: true), + EndUtc = table.Column(type: "timestamp with time zone", nullable: true), + TimeZoneId = table.Column(type: "character varying(128)", maxLength: 128, nullable: true), + RecurrenceId = table.Column(type: "character varying(512)", maxLength: 512, nullable: true), + Location = table.Column(type: "character varying(512)", maxLength: 512, nullable: true), + SourceUrl = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: true), + SourceLastModifiedAt = table.Column(type: "timestamp with time zone", nullable: true), + ImportedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CalendarEvents", x => x.Id); + table.ForeignKey( + name: "FK_CalendarEvents_CalendarSources_CalendarSourceId", + column: x => x.CalendarSourceId, + principalTable: "CalendarSources", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + migrationBuilder.CreateTable( name: "FeedbackActivityEntries", columns: table => new @@ -620,6 +761,16 @@ namespace Socialize.Api.Migrations onDelete: ReferentialAction.Restrict); }); + migrationBuilder.InsertData( + table: "CalendarCatalogEntries", + columns: new[] { "Id", "Category", "Country", "CultureOrReligion", "DefaultColor", "Description", "Language", "ProviderName", "Region", "SourceUrl", "Title", "TrustLevel" }, + values: new object[,] + { + { new Guid("10000000-0000-0000-0000-000000000001"), "public-holiday", "US", null, "#2F80ED", "Federal public holiday calendar for the United States.", "en", "Nager.Date", null, "https://date.nager.at/api/v3/PublicHolidays/2026/US", "United States Public Holidays", "Verified" }, + { new Guid("10000000-0000-0000-0000-000000000002"), "public-holiday", "CA", null, "#2F80ED", "Public holiday calendar for Canada.", "en", "Nager.Date", null, "https://date.nager.at/api/v3/PublicHolidays/2026/CA", "Canada Public Holidays", "Verified" }, + { new Guid("10000000-0000-0000-0000-000000000003"), "marketing-moment", null, null, "#9B51E0", "Common retail, awareness, and social planning moments.", "en", "Socialize", null, "https://example.com/socialize/marketing-moments.ics", "Common Marketing Moments", "Maintained" } + }); + migrationBuilder.CreateIndex( name: "IX_ApprovalDecisions_ApprovalRequestId", table: "ApprovalDecisions", @@ -720,6 +871,52 @@ namespace Socialize.Api.Migrations table: "Assets", column: "WorkspaceId"); + migrationBuilder.CreateIndex( + name: "IX_CalendarCatalogEntries_Category", + table: "CalendarCatalogEntries", + column: "Category"); + + migrationBuilder.CreateIndex( + name: "IX_CalendarCatalogEntries_Country", + table: "CalendarCatalogEntries", + column: "Country"); + + migrationBuilder.CreateIndex( + name: "IX_CalendarCatalogEntries_ProviderName", + table: "CalendarCatalogEntries", + column: "ProviderName"); + + migrationBuilder.CreateIndex( + name: "IX_CalendarEvents_CalendarSourceId", + table: "CalendarEvents", + column: "CalendarSourceId"); + + migrationBuilder.CreateIndex( + name: "IX_CalendarEvents_CalendarSourceId_SourceEventUid_StartDate", + table: "CalendarEvents", + columns: new[] { "CalendarSourceId", "SourceEventUid", "StartDate" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_CalendarSources_OrganizationId", + table: "CalendarSources", + column: "OrganizationId"); + + migrationBuilder.CreateIndex( + name: "IX_CalendarSources_Scope", + table: "CalendarSources", + column: "Scope"); + + migrationBuilder.CreateIndex( + name: "IX_CalendarSources_UserId", + table: "CalendarSources", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_CalendarSources_WorkspaceId", + table: "CalendarSources", + column: "WorkspaceId"); + migrationBuilder.CreateIndex( name: "IX_Campaigns_ClientId", table: "Campaigns", @@ -736,6 +933,17 @@ namespace Socialize.Api.Migrations table: "Campaigns", column: "WorkspaceId"); + 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); + migrationBuilder.CreateIndex( name: "IX_Clients_WorkspaceId", table: "Clients", @@ -762,6 +970,21 @@ namespace Socialize.Api.Migrations table: "Comments", column: "WorkspaceId"); + migrationBuilder.CreateIndex( + name: "IX_ContentItemActivityEntries_ContentItemId", + table: "ContentItemActivityEntries", + column: "ContentItemId"); + + migrationBuilder.CreateIndex( + name: "IX_ContentItemActivityEntries_ContentItemId_CreatedAt", + table: "ContentItemActivityEntries", + columns: new[] { "ContentItemId", "CreatedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_ContentItemActivityEntries_WorkspaceId", + table: "ContentItemActivityEntries", + column: "WorkspaceId"); + migrationBuilder.CreateIndex( name: "IX_ContentItemRevisions_ContentItemId", table: "ContentItemRevisions", @@ -901,6 +1124,18 @@ namespace Socialize.Api.Migrations table: "Organizations", column: "OwnerUserId"); + migrationBuilder.CreateIndex( + name: "IX_UserCalendarExportFeeds_TokenHash", + table: "UserCalendarExportFeeds", + column: "TokenHash", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_UserCalendarExportFeeds_UserId", + table: "UserCalendarExportFeeds", + column: "UserId", + unique: true); + migrationBuilder.CreateIndex( name: "IX_WorkspaceApprovalStepConfigurations_WorkspaceId", table: "WorkspaceApprovalStepConfigurations", @@ -966,15 +1201,27 @@ namespace Socialize.Api.Migrations migrationBuilder.DropTable( name: "Assets"); + migrationBuilder.DropTable( + name: "CalendarCatalogEntries"); + + migrationBuilder.DropTable( + name: "CalendarEvents"); + migrationBuilder.DropTable( name: "Campaigns"); + migrationBuilder.DropTable( + name: "Channels"); + migrationBuilder.DropTable( name: "Clients"); migrationBuilder.DropTable( name: "Comments"); + migrationBuilder.DropTable( + name: "ContentItemActivityEntries"); + migrationBuilder.DropTable( name: "ContentItemRevisions"); @@ -999,6 +1246,9 @@ namespace Socialize.Api.Migrations migrationBuilder.DropTable( name: "OrganizationMemberships"); + migrationBuilder.DropTable( + name: "UserCalendarExportFeeds"); + migrationBuilder.DropTable( name: "WorkspaceApprovalStepConfigurations"); @@ -1014,6 +1264,9 @@ namespace Socialize.Api.Migrations migrationBuilder.DropTable( name: "AspNetUsers"); + migrationBuilder.DropTable( + name: "CalendarSources"); + migrationBuilder.DropTable( name: "FeedbackReports"); diff --git a/backend/src/Socialize.Api/Migrations/AppDbContextModelSnapshot.cs b/backend/src/Socialize.Api/Migrations/AppDbContextModelSnapshot.cs index 494ff38..22b5e91 100644 --- a/backend/src/Socialize.Api/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/Socialize.Api/Migrations/AppDbContextModelSnapshot.cs @@ -438,6 +438,326 @@ namespace Socialize.Api.Migrations 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") @@ -615,15 +935,9 @@ namespace Socialize.Api.Migrations .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"); @@ -704,6 +1018,62 @@ namespace Socialize.Api.Migrations 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") @@ -1256,6 +1626,10 @@ namespace Socialize.Api.Migrations .HasColumnType("timestamp with time zone") .HasDefaultValueSql("CURRENT_TIMESTAMP"); + b.Property("LogoUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + b.Property("Name") .IsRequired() .HasMaxLength(256) @@ -1459,6 +1833,15 @@ namespace Socialize.Api.Migrations .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.Feedback.Data.FeedbackActivityEntry", b => { b.HasOne("Socialize.Api.Modules.Feedback.Data.FeedbackReport", "FeedbackReport") diff --git a/backend/src/Socialize.Api/Modules/Approvals/Handlers/SubmitApprovalDecision.cs b/backend/src/Socialize.Api/Modules/Approvals/Handlers/SubmitApprovalDecision.cs index 85486de..d9c69d9 100644 --- a/backend/src/Socialize.Api/Modules/Approvals/Handlers/SubmitApprovalDecision.cs +++ b/backend/src/Socialize.Api/Modules/Approvals/Handlers/SubmitApprovalDecision.cs @@ -3,10 +3,12 @@ using Microsoft.EntityFrameworkCore; using Socialize.Api.Data; using Socialize.Api.Infrastructure.Security; using Socialize.Api.Modules.ContentItems.Data; +using Socialize.Api.Modules.ContentItems.Contracts; using Socialize.Api.Modules.Approvals.Data; using Socialize.Api.Modules.Approvals.Services; using Socialize.Api.Modules.Notifications.Contracts; using Socialize.Api.Modules.Workspaces.Data; +using System.Text.Json; namespace Socialize.Api.Modules.Approvals.Handlers; @@ -33,6 +35,7 @@ public class SubmitApprovalDecisionHandler( AppDbContext dbContext, AccessScopeService accessScopeService, ApprovalWorkflowRuntimeService approvalWorkflowRuntimeService, + IContentItemActivityWriter activityWriter, INotificationEventWriter notificationEventWriter) : Endpoint { @@ -120,6 +123,24 @@ public class SubmitApprovalDecisionHandler( dbContext.ApprovalDecisions.Add(decision); await dbContext.SaveChangesAsync(ct); + await activityWriter.WriteAsync( + new ContentItemActivityWriteModel( + approval.WorkspaceId, + approval.ContentItemId, + "approval.decision.recorded", + "ApprovalDecision", + decision.Id, + $"{decidedByName} recorded {normalizedDecision} for {contentItem.Title}.", + decision.DecidedByUserId, + decidedByEmail, + JsonSerializer.Serialize(new + { + stage = approval.Stage, + status = contentItem.Status, + decision = normalizedDecision, + })), + ct); + await notificationEventWriter.WriteAsync( new NotificationEventWriteModel( approval.WorkspaceId, diff --git a/backend/src/Socialize.Api/Modules/Assets/Handlers/CreateAssetRevision.cs b/backend/src/Socialize.Api/Modules/Assets/Handlers/CreateAssetRevision.cs index 86ab179..1739276 100644 --- a/backend/src/Socialize.Api/Modules/Assets/Handlers/CreateAssetRevision.cs +++ b/backend/src/Socialize.Api/Modules/Assets/Handlers/CreateAssetRevision.cs @@ -3,8 +3,10 @@ using Microsoft.EntityFrameworkCore; using Socialize.Api.Data; using Socialize.Api.Infrastructure.Security; 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 System.Text.Json; namespace Socialize.Api.Modules.Assets.Handlers; @@ -27,6 +29,7 @@ public class CreateAssetRevisionRequestValidator public class CreateAssetRevisionHandler( AppDbContext dbContext, AccessScopeService accessScopeService, + IContentItemActivityWriter activityWriter, INotificationEventWriter notificationEventWriter) : Endpoint { @@ -78,6 +81,25 @@ public class CreateAssetRevisionHandler( if (contentItem is not null) { + await activityWriter.WriteAsync( + new ContentItemActivityWriteModel( + asset.WorkspaceId, + asset.ContentItemId, + "asset.revision.created", + "AssetRevision", + revision.Id, + $"A new asset revision was added to {asset.DisplayName}.", + User.GetUserId(), + User.GetEmail(), + JsonSerializer.Serialize(new + { + assetId = asset.Id, + revisionNumber, + sourceReference = revision.SourceReference, + notes = revision.Notes, + })), + ct); + await notificationEventWriter.WriteAsync( new NotificationEventWriteModel( asset.WorkspaceId, diff --git a/backend/src/Socialize.Api/Modules/Assets/Handlers/CreateGoogleDriveAsset.cs b/backend/src/Socialize.Api/Modules/Assets/Handlers/CreateGoogleDriveAsset.cs index 8b1d5a8..3a55512 100644 --- a/backend/src/Socialize.Api/Modules/Assets/Handlers/CreateGoogleDriveAsset.cs +++ b/backend/src/Socialize.Api/Modules/Assets/Handlers/CreateGoogleDriveAsset.cs @@ -3,8 +3,10 @@ using Microsoft.EntityFrameworkCore; using Socialize.Api.Data; using Socialize.Api.Infrastructure.Security; 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 System.Text.Json; namespace Socialize.Api.Modules.Assets.Handlers; @@ -35,6 +37,7 @@ public class CreateGoogleDriveAssetRequestValidator public class CreateGoogleDriveAssetHandler( AppDbContext dbContext, AccessScopeService accessScopeService, + IContentItemActivityWriter activityWriter, INotificationEventWriter notificationEventWriter) : Endpoint { @@ -93,6 +96,25 @@ public class CreateGoogleDriveAssetHandler( dbContext.AssetRevisions.Add(revision); await dbContext.SaveChangesAsync(ct); + await activityWriter.WriteAsync( + new ContentItemActivityWriteModel( + asset.WorkspaceId, + asset.ContentItemId, + "asset.google-drive-linked", + "Asset", + asset.Id, + $"Google Drive asset {asset.DisplayName} was linked to {contentItem.Title}.", + User.GetUserId(), + User.GetEmail(), + JsonSerializer.Serialize(new + { + assetType = asset.AssetType, + sourceType = asset.SourceType, + googleDriveFileId = asset.GoogleDriveFileId, + currentRevisionNumber = asset.CurrentRevisionNumber, + })), + ct); + await notificationEventWriter.WriteAsync( new NotificationEventWriteModel( asset.WorkspaceId, diff --git a/backend/src/Socialize.Api/Modules/CalendarIntegrations/Data/CalendarCatalogEntry.cs b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Data/CalendarCatalogEntry.cs new file mode 100644 index 0000000..cdb241b --- /dev/null +++ b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Data/CalendarCatalogEntry.cs @@ -0,0 +1,18 @@ +namespace Socialize.Api.Modules.CalendarIntegrations.Data; + +public class CalendarCatalogEntry +{ + public Guid Id { get; init; } + public required string Title { get; set; } + public required string Description { get; set; } + public string? Country { get; set; } + public string? Region { get; set; } + public required string Language { get; set; } + public required string Category { get; set; } + public string? CultureOrReligion { get; set; } + public required string ProviderName { get; set; } + public required string SourceUrl { get; set; } + public required string TrustLevel { get; set; } + public required string DefaultColor { get; set; } + public DateTimeOffset CreatedAt { get; init; } +} diff --git a/backend/src/Socialize.Api/Modules/CalendarIntegrations/Data/CalendarCatalogSeed.cs b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Data/CalendarCatalogSeed.cs new file mode 100644 index 0000000..0a9d4ec --- /dev/null +++ b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Data/CalendarCatalogSeed.cs @@ -0,0 +1,53 @@ +namespace Socialize.Api.Modules.CalendarIntegrations.Data; + +public static class CalendarCatalogSeed +{ + public static readonly CalendarCatalogEntry[] Entries = + [ + new() + { + Id = Guid.Parse("10000000-0000-0000-0000-000000000001"), + Title = "United States Public Holidays", + Description = "Federal public holiday calendar for the United States.", + Country = "US", + Region = null, + Language = "en", + Category = "public-holiday", + CultureOrReligion = null, + ProviderName = "Nager.Date", + SourceUrl = "https://date.nager.at/api/v3/PublicHolidays/2026/US", + TrustLevel = "Verified", + DefaultColor = "#2F80ED", + }, + new() + { + Id = Guid.Parse("10000000-0000-0000-0000-000000000002"), + Title = "Canada Public Holidays", + Description = "Public holiday calendar for Canada.", + Country = "CA", + Region = null, + Language = "en", + Category = "public-holiday", + CultureOrReligion = null, + ProviderName = "Nager.Date", + SourceUrl = "https://date.nager.at/api/v3/PublicHolidays/2026/CA", + TrustLevel = "Verified", + DefaultColor = "#2F80ED", + }, + new() + { + Id = Guid.Parse("10000000-0000-0000-0000-000000000003"), + Title = "Common Marketing Moments", + Description = "Common retail, awareness, and social planning moments.", + Country = null, + Region = null, + Language = "en", + Category = "marketing-moment", + CultureOrReligion = null, + ProviderName = "Socialize", + SourceUrl = "https://example.com/socialize/marketing-moments.ics", + TrustLevel = "Maintained", + DefaultColor = "#9B51E0", + }, + ]; +} diff --git a/backend/src/Socialize.Api/Modules/CalendarIntegrations/Data/CalendarEvent.cs b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Data/CalendarEvent.cs new file mode 100644 index 0000000..e173c1c --- /dev/null +++ b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Data/CalendarEvent.cs @@ -0,0 +1,24 @@ +namespace Socialize.Api.Modules.CalendarIntegrations.Data; + +public class CalendarEvent +{ + public Guid Id { get; init; } + public Guid CalendarSourceId { get; set; } + public required string SourceEventUid { get; set; } + public required string Title { get; set; } + public string? Description { get; set; } + public bool IsAllDay { get; set; } + public bool IsFloatingTime { get; set; } + public DateOnly StartDate { get; set; } + public DateOnly EndDate { get; set; } + public DateTime? StartLocalDateTime { get; set; } + public DateTime? EndLocalDateTime { get; set; } + public DateTimeOffset? StartUtc { get; set; } + public DateTimeOffset? EndUtc { get; set; } + public string? TimeZoneId { get; set; } + public string? RecurrenceId { get; set; } + public string? Location { get; set; } + public string? SourceUrl { get; set; } + public DateTimeOffset? SourceLastModifiedAt { get; set; } + public DateTimeOffset ImportedAt { get; set; } +} diff --git a/backend/src/Socialize.Api/Modules/CalendarIntegrations/Data/CalendarSource.cs b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Data/CalendarSource.cs new file mode 100644 index 0000000..a513365 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Data/CalendarSource.cs @@ -0,0 +1,22 @@ +namespace Socialize.Api.Modules.CalendarIntegrations.Data; + +public class CalendarSource +{ + public Guid Id { get; init; } + public required string Scope { get; set; } + public Guid? OrganizationId { get; set; } + public Guid? WorkspaceId { get; set; } + public Guid? UserId { get; set; } + public string? SourceUrl { get; set; } + public string? CatalogSourceReference { get; set; } + public required string DisplayTitle { get; set; } + public required string Color { get; set; } + public required string Category { get; set; } + public bool IsEnabled { get; set; } = true; + public string? InheritanceMode { get; set; } + public DateTimeOffset? LastSuccessfulSyncAt { get; set; } + public DateTimeOffset? LastAttemptedSyncAt { get; set; } + public string? LastSyncError { get; set; } + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset UpdatedAt { get; set; } +} diff --git a/backend/src/Socialize.Api/Modules/CalendarIntegrations/Data/CalendarSourceModelConfiguration.cs b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Data/CalendarSourceModelConfiguration.cs new file mode 100644 index 0000000..cc30434 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Data/CalendarSourceModelConfiguration.cs @@ -0,0 +1,95 @@ +using Microsoft.EntityFrameworkCore; + +namespace Socialize.Api.Modules.CalendarIntegrations.Data; + +public static class CalendarSourceModelConfiguration +{ + public static ModelBuilder ConfigureCalendarIntegrationsModule(this ModelBuilder modelBuilder) + { + modelBuilder.Entity(source => + { + source.ToTable("CalendarSources"); + source.HasKey(x => x.Id); + source.Property(x => x.Scope).HasMaxLength(32).IsRequired(); + source.Property(x => x.SourceUrl).HasMaxLength(2048); + source.Property(x => x.CatalogSourceReference).HasMaxLength(256); + source.Property(x => x.DisplayTitle).HasMaxLength(256).IsRequired(); + source.Property(x => x.Color).HasMaxLength(16).IsRequired(); + source.Property(x => x.Category).HasMaxLength(64).IsRequired(); + source.Property(x => x.InheritanceMode).HasMaxLength(32); + source.Property(x => x.LastSyncError).HasMaxLength(2048); + source.Property(x => x.CreatedAt) + .ValueGeneratedOnAdd() + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + source.Property(x => x.UpdatedAt) + .ValueGeneratedOnAdd() + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + source.HasIndex(x => x.Scope); + source.HasIndex(x => x.OrganizationId); + source.HasIndex(x => x.WorkspaceId); + source.HasIndex(x => x.UserId); + }); + + modelBuilder.Entity(entry => + { + entry.ToTable("CalendarCatalogEntries"); + entry.HasKey(x => x.Id); + entry.Property(x => x.Title).HasMaxLength(256).IsRequired(); + entry.Property(x => x.Description).HasMaxLength(1024).IsRequired(); + entry.Property(x => x.Country).HasMaxLength(2); + entry.Property(x => x.Region).HasMaxLength(128); + entry.Property(x => x.Language).HasMaxLength(16).IsRequired(); + entry.Property(x => x.Category).HasMaxLength(64).IsRequired(); + entry.Property(x => x.CultureOrReligion).HasMaxLength(128); + entry.Property(x => x.ProviderName).HasMaxLength(128).IsRequired(); + entry.Property(x => x.SourceUrl).HasMaxLength(2048).IsRequired(); + entry.Property(x => x.TrustLevel).HasMaxLength(64).IsRequired(); + entry.Property(x => x.DefaultColor).HasMaxLength(16).IsRequired(); + entry.Property(x => x.CreatedAt) + .ValueGeneratedOnAdd() + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + entry.HasIndex(x => x.Country); + entry.HasIndex(x => x.Category); + entry.HasIndex(x => x.ProviderName); + entry.HasData(CalendarCatalogSeed.Entries); + }); + + modelBuilder.Entity(calendarEvent => + { + calendarEvent.ToTable("CalendarEvents"); + calendarEvent.HasKey(x => x.Id); + calendarEvent.Property(x => x.SourceEventUid).HasMaxLength(512).IsRequired(); + calendarEvent.Property(x => x.Title).HasMaxLength(512).IsRequired(); + calendarEvent.Property(x => x.Description).HasMaxLength(4000); + calendarEvent.Property(x => x.TimeZoneId).HasMaxLength(128); + calendarEvent.Property(x => x.RecurrenceId).HasMaxLength(512); + calendarEvent.Property(x => x.Location).HasMaxLength(512); + calendarEvent.Property(x => x.SourceUrl).HasMaxLength(2048); + calendarEvent.HasIndex(x => x.CalendarSourceId); + calendarEvent.HasIndex(x => new { x.CalendarSourceId, x.SourceEventUid, x.StartDate }).IsUnique(); + calendarEvent.HasOne() + .WithMany() + .HasForeignKey(x => x.CalendarSourceId) + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(feed => + { + feed.ToTable("UserCalendarExportFeeds"); + feed.HasKey(x => x.Id); + feed.Property(x => x.Token).HasMaxLength(96); + feed.Property(x => x.TokenHash).HasMaxLength(64); + feed.Property(x => x.CreatedAt) + .ValueGeneratedOnAdd() + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + feed.Property(x => x.UpdatedAt) + .ValueGeneratedOnAdd() + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + feed.HasIndex(x => x.UserId).IsUnique(); + feed.HasIndex(x => x.TokenHash).IsUnique(); + }); + + return modelBuilder; + } +} diff --git a/backend/src/Socialize.Api/Modules/CalendarIntegrations/Data/UserCalendarExportFeed.cs b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Data/UserCalendarExportFeed.cs new file mode 100644 index 0000000..eff6ff0 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Data/UserCalendarExportFeed.cs @@ -0,0 +1,12 @@ +namespace Socialize.Api.Modules.CalendarIntegrations.Data; + +public class UserCalendarExportFeed +{ + public Guid Id { get; init; } + public Guid UserId { get; set; } + public string? Token { get; set; } + public string? TokenHash { get; set; } + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset UpdatedAt { get; set; } + public DateTimeOffset? RevokedAt { get; set; } +} diff --git a/backend/src/Socialize.Api/Modules/CalendarIntegrations/DependencyInjection.cs b/backend/src/Socialize.Api/Modules/CalendarIntegrations/DependencyInjection.cs new file mode 100644 index 0000000..ca266c7 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/CalendarIntegrations/DependencyInjection.cs @@ -0,0 +1,15 @@ +namespace Socialize.Api.Modules.CalendarIntegrations; + +public static class DependencyInjection +{ + public static WebApplicationBuilder AddCalendarIntegrationsModule(this WebApplicationBuilder builder) + { + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddHostedService(); + + return builder; + } +} diff --git a/backend/src/Socialize.Api/Modules/CalendarIntegrations/Handlers/CalendarSourceDtos.cs b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Handlers/CalendarSourceDtos.cs new file mode 100644 index 0000000..fe00b4c --- /dev/null +++ b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Handlers/CalendarSourceDtos.cs @@ -0,0 +1,112 @@ +using Socialize.Api.Modules.CalendarIntegrations.Data; +using Socialize.Api.Modules.CalendarIntegrations.Services; + +namespace Socialize.Api.Modules.CalendarIntegrations.Handlers; + +public record CalendarSourceDto( + Guid Id, + string Scope, + Guid? OrganizationId, + Guid? WorkspaceId, + Guid? UserId, + string? SourceUrl, + string? CatalogSourceReference, + string DisplayTitle, + string Color, + string Category, + bool IsEnabled, + string? InheritanceMode, + bool IsReadOnly, + DateTimeOffset? LastSuccessfulSyncAt, + DateTimeOffset? LastAttemptedSyncAt, + string? LastSyncError, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt) +{ + public static CalendarSourceDto FromSource(CalendarSource source, bool isReadOnly) + { + return new CalendarSourceDto( + source.Id, + source.Scope, + source.OrganizationId, + source.WorkspaceId, + source.UserId, + source.SourceUrl, + source.CatalogSourceReference, + source.DisplayTitle, + source.Color, + source.Category, + source.IsEnabled, + source.InheritanceMode, + isReadOnly, + source.LastSuccessfulSyncAt, + source.LastAttemptedSyncAt, + source.LastSyncError, + source.CreatedAt, + source.UpdatedAt); + } +} + +public record UpsertCalendarSourceRequest( + string Scope, + Guid? OrganizationId, + Guid? WorkspaceId, + string? SourceUrl, + string? CatalogSourceReference, + string DisplayTitle, + string Color, + string Category, + bool IsEnabled, + string? InheritanceMode); + +public class UpsertCalendarSourceRequestValidator + : FastEndpoints.Validator +{ + public UpsertCalendarSourceRequestValidator() + { + RuleFor(x => x.Scope) + .NotEmpty() + .Must(CalendarSourceRules.IsSupportedScope) + .WithMessage("A valid calendar source scope should be specified."); + + RuleFor(x => x.DisplayTitle).NotEmpty().MaximumLength(256); + RuleFor(x => x.Category).NotEmpty().MaximumLength(64); + RuleFor(x => x.Color) + .NotEmpty() + .Matches("^#[0-9A-Fa-f]{6}$") + .WithMessage("Color should be a six digit hex color, for example #2F80ED."); + + RuleFor(x => x.SourceUrl) + .MaximumLength(2048) + .Must(value => string.IsNullOrWhiteSpace(value) || Uri.TryCreate(value, UriKind.Absolute, out Uri? uri) && + (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)) + .WithMessage("Source URL should be an absolute HTTP or HTTPS URL."); + + RuleFor(x => x.CatalogSourceReference).MaximumLength(256); + + RuleFor(x => x) + .Must(x => !string.IsNullOrWhiteSpace(x.SourceUrl) || !string.IsNullOrWhiteSpace(x.CatalogSourceReference)) + .WithMessage("A source URL or catalog source reference should be specified."); + + RuleFor(x => x) + .Must(x => x.Scope != CalendarSourceScopes.Organization || x.OrganizationId.HasValue) + .WithMessage("Organization calendar sources require an organization id."); + + RuleFor(x => x) + .Must(x => x.Scope != CalendarSourceScopes.Workspace || x.WorkspaceId.HasValue) + .WithMessage("Workspace calendar sources require a workspace id."); + + RuleFor(x => x) + .Must(x => x.Scope != CalendarSourceScopes.User || (!x.OrganizationId.HasValue && !x.WorkspaceId.HasValue)) + .WithMessage("User calendar sources should not include organization or workspace ids."); + + RuleFor(x => x) + .Must(x => x.Scope == CalendarSourceScopes.Organization || + (!x.OrganizationId.HasValue && string.IsNullOrWhiteSpace(x.InheritanceMode))) + .WithMessage("Only organization calendar sources can set organization ids or inheritance modes."); + + RuleFor(x => x.InheritanceMode) + .Must(value => string.IsNullOrWhiteSpace(value) || CalendarSourceRules.IsSupportedInheritanceMode(value)) + .WithMessage("A valid inheritance mode should be specified."); + } +} diff --git a/backend/src/Socialize.Api/Modules/CalendarIntegrations/Handlers/CreateCalendarSource.cs b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Handlers/CreateCalendarSource.cs new file mode 100644 index 0000000..5463bdd --- /dev/null +++ b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Handlers/CreateCalendarSource.cs @@ -0,0 +1,132 @@ +using FastEndpoints; +using Microsoft.EntityFrameworkCore; +using Socialize.Api.Data; +using Socialize.Api.Infrastructure.Security; +using Socialize.Api.Modules.CalendarIntegrations.Data; +using Socialize.Api.Modules.CalendarIntegrations.Services; +using Socialize.Api.Modules.Organizations.Services; + +namespace Socialize.Api.Modules.CalendarIntegrations.Handlers; + +public class CreateCalendarSourceHandler( + AppDbContext dbContext, + AccessScopeService accessScopeService, + OrganizationAccessService organizationAccessService) + : Endpoint +{ + public override void Configure() + { + Post("/api/calendar-integrations/sources"); + Options(o => o.WithTags("Calendar Integrations")); + } + + public override async Task HandleAsync(UpsertCalendarSourceRequest request, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(request); + + Guid currentUserId = User.GetUserId(); + string scope = request.Scope.Trim(); + Guid? organizationId = request.OrganizationId; + Guid? workspaceId = request.WorkspaceId; + + if (!await CanCreateAsync(scope, organizationId, workspaceId, currentUserId, ct)) + { + await SendForbiddenAsync(ct); + return; + } + + string? sourceUrl = NormalizeOptional(request.SourceUrl); + string? catalogSourceReference = NormalizeOptional(request.CatalogSourceReference); + if (await SourceAlreadyExistsAsync(scope, organizationId, workspaceId, currentUserId, sourceUrl, catalogSourceReference, ct)) + { + AddError(request => request.SourceUrl, "This calendar source has already been added."); + await SendErrorsAsync(cancellation: ct); + return; + } + + CalendarSource source = new() + { + Id = Guid.NewGuid(), + Scope = scope, + OrganizationId = scope == CalendarSourceScopes.Organization ? organizationId : null, + WorkspaceId = scope == CalendarSourceScopes.Workspace ? workspaceId : null, + UserId = scope == CalendarSourceScopes.User ? currentUserId : null, + SourceUrl = sourceUrl, + CatalogSourceReference = catalogSourceReference, + DisplayTitle = request.DisplayTitle.Trim(), + Color = request.Color.Trim(), + Category = request.Category.Trim(), + IsEnabled = request.IsEnabled, + InheritanceMode = scope == CalendarSourceScopes.Organization + ? NormalizeOptional(request.InheritanceMode) ?? CalendarSourceInheritanceModes.Optional + : null, + UpdatedAt = DateTimeOffset.UtcNow, + }; + + dbContext.CalendarSources.Add(source); + await dbContext.SaveChangesAsync(ct); + + await SendAsync(CalendarSourceDto.FromSource(source, isReadOnly: false), StatusCodes.Status201Created, ct); + } + + private async Task CanCreateAsync( + string scope, + Guid? organizationId, + Guid? workspaceId, + Guid currentUserId, + CancellationToken ct) + { + return scope switch + { + CalendarSourceScopes.Organization when organizationId.HasValue => + await dbContext.Organizations.AnyAsync(organization => organization.Id == organizationId.Value, ct) && + await organizationAccessService.HasOrganizationPermissionAsync( + User, + organizationId.Value, + OrganizationPermissions.ManageConnectors, + ct), + CalendarSourceScopes.Workspace when workspaceId.HasValue => + await dbContext.Workspaces.AnyAsync(workspace => workspace.Id == workspaceId.Value, ct) && + await accessScopeService.CanManageWorkspaceAsync(User, workspaceId.Value, ct), + CalendarSourceScopes.User => currentUserId != Guid.Empty, + _ => false, + }; + } + + private Task SourceAlreadyExistsAsync( + string scope, + Guid? organizationId, + Guid? workspaceId, + Guid currentUserId, + string? sourceUrl, + string? catalogSourceReference, + CancellationToken ct) + { + IQueryable query = dbContext.CalendarSources + .Where(source => source.Scope == scope); + + query = scope switch + { + CalendarSourceScopes.Organization => query.Where(source => source.OrganizationId == organizationId), + CalendarSourceScopes.Workspace => query.Where(source => source.WorkspaceId == workspaceId), + CalendarSourceScopes.User => query.Where(source => source.UserId == currentUserId), + _ => query.Where(_ => false), + }; + + string? normalizedUrl = sourceUrl?.Trim(); + string? normalizedCatalogReference = catalogSourceReference?.Trim(); + + return query.AnyAsync(source => + (!string.IsNullOrWhiteSpace(normalizedCatalogReference) && + source.CatalogSourceReference == normalizedCatalogReference) || + (!string.IsNullOrWhiteSpace(normalizedUrl) && + source.SourceUrl != null && + source.SourceUrl.ToUpper() == normalizedUrl.ToUpper()), + ct); + } + + private static string? NormalizeOptional(string? value) + { + return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } +} diff --git a/backend/src/Socialize.Api/Modules/CalendarIntegrations/Handlers/DeleteCalendarSource.cs b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Handlers/DeleteCalendarSource.cs new file mode 100644 index 0000000..c83e613 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Handlers/DeleteCalendarSource.cs @@ -0,0 +1,64 @@ +using FastEndpoints; +using Microsoft.EntityFrameworkCore; +using Socialize.Api.Data; +using Socialize.Api.Infrastructure.Security; +using Socialize.Api.Modules.CalendarIntegrations.Data; +using Socialize.Api.Modules.CalendarIntegrations.Services; +using Socialize.Api.Modules.Organizations.Services; + +namespace Socialize.Api.Modules.CalendarIntegrations.Handlers; + +public class DeleteCalendarSourceHandler( + AppDbContext dbContext, + AccessScopeService accessScopeService, + OrganizationAccessService organizationAccessService) + : EndpointWithoutRequest +{ + public override void Configure() + { + Delete("/api/calendar-integrations/sources/{sourceId:guid}"); + Options(o => o.WithTags("Calendar Integrations")); + } + + public override async Task HandleAsync(CancellationToken ct) + { + Guid sourceId = Route("sourceId"); + CalendarSource? source = await dbContext.CalendarSources.SingleOrDefaultAsync(candidate => candidate.Id == sourceId, ct); + if (source is null) + { + await SendNotFoundAsync(ct); + return; + } + + if (!await CanManageExistingSourceAsync(source, User.GetUserId(), ct)) + { + await SendForbiddenAsync(ct); + return; + } + + dbContext.CalendarSources.Remove(source); + await dbContext.SaveChangesAsync(ct); + + await SendNoContentAsync(ct); + } + + private async Task CanManageExistingSourceAsync( + CalendarSource source, + Guid currentUserId, + CancellationToken ct) + { + return source.Scope switch + { + CalendarSourceScopes.Organization when source.OrganizationId.HasValue => + await organizationAccessService.HasOrganizationPermissionAsync( + User, + source.OrganizationId.Value, + OrganizationPermissions.ManageConnectors, + ct), + CalendarSourceScopes.Workspace when source.WorkspaceId.HasValue => + await accessScopeService.CanManageWorkspaceAsync(User, source.WorkspaceId.Value, ct), + CalendarSourceScopes.User => source.UserId == currentUserId, + _ => false, + }; + } +} diff --git a/backend/src/Socialize.Api/Modules/CalendarIntegrations/Handlers/ListCalendarCatalog.cs b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Handlers/ListCalendarCatalog.cs new file mode 100644 index 0000000..2a8a2e2 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Handlers/ListCalendarCatalog.cs @@ -0,0 +1,115 @@ +using FastEndpoints; +using Microsoft.EntityFrameworkCore; +using Socialize.Api.Data; +using Socialize.Api.Modules.CalendarIntegrations.Data; + +namespace Socialize.Api.Modules.CalendarIntegrations.Handlers; + +public sealed class ListCalendarCatalogRequest +{ + public string? Search { get; set; } + public string? Country { get; set; } + public string? Region { get; set; } + public string? Language { get; set; } + public string? Category { get; set; } + public string? CultureOrReligion { get; set; } + public string? Provider { get; set; } +} + +public record CalendarCatalogEntryDto( + Guid Id, + string Title, + string Description, + string? Country, + string? Region, + string Language, + string Category, + string? CultureOrReligion, + string ProviderName, + string SourceUrl, + string TrustLevel, + string DefaultColor); + +public class ListCalendarCatalogHandler(AppDbContext dbContext) + : Endpoint> +{ + public override void Configure() + { + Get("/api/calendar-integrations/catalog"); + Options(o => o.WithTags("Calendar Integrations")); + } + + public override async Task HandleAsync(ListCalendarCatalogRequest request, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(request); + + IQueryable query = dbContext.CalendarCatalogEntries.AsQueryable(); + + if (!string.IsNullOrWhiteSpace(request.Search)) + { + string search = request.Search.Trim().ToLowerInvariant(); + query = query.Where(entry => + entry.Title.ToLower().Contains(search) || + entry.Description.ToLower().Contains(search) || + entry.ProviderName.ToLower().Contains(search)); + } + + if (!string.IsNullOrWhiteSpace(request.Country)) + { + string country = request.Country.Trim().ToUpperInvariant(); + query = query.Where(entry => entry.Country == country); + } + + if (!string.IsNullOrWhiteSpace(request.Region)) + { + string region = request.Region.Trim(); + query = query.Where(entry => entry.Region == region); + } + + if (!string.IsNullOrWhiteSpace(request.Language)) + { + string language = request.Language.Trim(); + query = query.Where(entry => entry.Language == language); + } + + if (!string.IsNullOrWhiteSpace(request.Category)) + { + string category = request.Category.Trim(); + query = query.Where(entry => entry.Category == category); + } + + if (!string.IsNullOrWhiteSpace(request.CultureOrReligion)) + { + string cultureOrReligion = request.CultureOrReligion.Trim(); + query = query.Where(entry => entry.CultureOrReligion == cultureOrReligion); + } + + if (!string.IsNullOrWhiteSpace(request.Provider)) + { + string provider = request.Provider.Trim(); + query = query.Where(entry => entry.ProviderName == provider); + } + + CalendarCatalogEntryDto[] entries = await query + .OrderBy(entry => entry.Country) + .ThenBy(entry => entry.Category) + .ThenBy(entry => entry.Title) + .Take(100) + .Select(entry => new CalendarCatalogEntryDto( + entry.Id, + entry.Title, + entry.Description, + entry.Country, + entry.Region, + entry.Language, + entry.Category, + entry.CultureOrReligion, + entry.ProviderName, + entry.SourceUrl, + entry.TrustLevel, + entry.DefaultColor)) + .ToArrayAsync(ct); + + await SendOkAsync(entries, ct); + } +} diff --git a/backend/src/Socialize.Api/Modules/CalendarIntegrations/Handlers/ListCalendarEvents.cs b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Handlers/ListCalendarEvents.cs new file mode 100644 index 0000000..6b38978 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Handlers/ListCalendarEvents.cs @@ -0,0 +1,133 @@ +using FastEndpoints; +using Microsoft.EntityFrameworkCore; +using Socialize.Api.Data; +using Socialize.Api.Infrastructure.Security; +using Socialize.Api.Modules.CalendarIntegrations.Data; +using Socialize.Api.Modules.CalendarIntegrations.Services; + +namespace Socialize.Api.Modules.CalendarIntegrations.Handlers; + +public sealed class ListCalendarEventsRequest +{ + public Guid? WorkspaceId { get; set; } + public DateOnly? StartDate { get; set; } + public DateOnly? EndDate { get; set; } +} + +public record CalendarEventDto( + Guid Id, + Guid CalendarSourceId, + string SourceEventUid, + string Title, + string? Description, + bool IsAllDay, + bool IsFloatingTime, + DateOnly StartDate, + DateOnly EndDate, + DateTime? StartLocalDateTime, + DateTime? EndLocalDateTime, + DateTimeOffset? StartUtc, + DateTimeOffset? EndUtc, + string? TimeZoneId, + string? RecurrenceId, + string? Location, + string? SourceUrl, + DateTimeOffset? SourceLastModifiedAt, + DateTimeOffset ImportedAt); + +public class ListCalendarEventsHandler( + AppDbContext dbContext, + AccessScopeService accessScopeService) + : Endpoint> +{ + public override void Configure() + { + Get("/api/calendar-integrations/events"); + Options(o => o.WithTags("Calendar Integrations")); + } + + public override async Task HandleAsync(ListCalendarEventsRequest request, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(request); + + Guid currentUserId = User.GetUserId(); + DateOnly startDate = request.StartDate ?? DateOnly.FromDateTime(DateTime.UtcNow.Date.AddMonths(-1)); + DateOnly endDate = request.EndDate ?? DateOnly.FromDateTime(DateTime.UtcNow.Date.AddMonths(3)); + + if (request.WorkspaceId.HasValue && + !await accessScopeService.CanAccessWorkspaceAsync(User, request.WorkspaceId.Value, ct)) + { + await SendForbiddenAsync(ct); + return; + } + + IQueryable visibleSources = dbContext.CalendarSources + .Where(source => source.IsEnabled); + + if (request.WorkspaceId.HasValue) + { + Guid? organizationId = await dbContext.Workspaces + .Where(workspace => workspace.Id == request.WorkspaceId.Value) + .Select(workspace => (Guid?)workspace.OrganizationId) + .SingleOrDefaultAsync(ct); + + if (!organizationId.HasValue) + { + await SendNotFoundAsync(ct); + return; + } + + visibleSources = visibleSources.Where(source => + source.Scope == CalendarSourceScopes.Organization && source.OrganizationId == organizationId || + source.Scope == CalendarSourceScopes.Workspace && source.WorkspaceId == request.WorkspaceId || + source.Scope == CalendarSourceScopes.User && source.UserId == currentUserId); + } + else + { + IReadOnlyCollection workspaceIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct); + Guid[] organizationIds = await dbContext.Workspaces + .Where(workspace => workspaceIds.Contains(workspace.Id)) + .Select(workspace => workspace.OrganizationId) + .Distinct() + .ToArrayAsync(ct); + + visibleSources = visibleSources.Where(source => + source.Scope == CalendarSourceScopes.Organization && source.OrganizationId.HasValue && organizationIds.Contains(source.OrganizationId.Value) || + source.Scope == CalendarSourceScopes.Workspace && source.WorkspaceId.HasValue && workspaceIds.Contains(source.WorkspaceId.Value) || + source.Scope == CalendarSourceScopes.User && source.UserId == currentUserId); + } + + Guid[] sourceIds = await visibleSources + .Select(source => source.Id) + .ToArrayAsync(ct); + + CalendarEventDto[] events = await dbContext.CalendarEvents + .Where(calendarEvent => sourceIds.Contains(calendarEvent.CalendarSourceId)) + .Where(calendarEvent => calendarEvent.StartDate <= endDate && calendarEvent.EndDate >= startDate) + .OrderBy(calendarEvent => calendarEvent.StartDate) + .ThenBy(calendarEvent => calendarEvent.Title) + .Select(calendarEvent => new CalendarEventDto( + calendarEvent.Id, + calendarEvent.CalendarSourceId, + calendarEvent.SourceEventUid, + calendarEvent.Title, + calendarEvent.Description, + calendarEvent.IsAllDay, + calendarEvent.IsFloatingTime, + calendarEvent.StartDate, + calendarEvent.EndDate, + calendarEvent.StartLocalDateTime, + calendarEvent.EndLocalDateTime, + calendarEvent.StartUtc, + calendarEvent.EndUtc, + calendarEvent.TimeZoneId, + calendarEvent.RecurrenceId, + calendarEvent.Location, + calendarEvent.SourceUrl, + calendarEvent.SourceLastModifiedAt, + calendarEvent.ImportedAt)) + .ToArrayAsync(ct); + + await SendOkAsync(events, ct); + } +} diff --git a/backend/src/Socialize.Api/Modules/CalendarIntegrations/Handlers/ListCalendarSources.cs b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Handlers/ListCalendarSources.cs new file mode 100644 index 0000000..be51336 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Handlers/ListCalendarSources.cs @@ -0,0 +1,77 @@ +using FastEndpoints; +using Microsoft.EntityFrameworkCore; +using Socialize.Api.Data; +using Socialize.Api.Infrastructure.Security; +using Socialize.Api.Modules.CalendarIntegrations.Data; +using Socialize.Api.Modules.CalendarIntegrations.Services; + +namespace Socialize.Api.Modules.CalendarIntegrations.Handlers; + +public record ListCalendarSourcesRequest(Guid? WorkspaceId); + +public class ListCalendarSourcesHandler( + AppDbContext dbContext, + AccessScopeService accessScopeService) + : Endpoint> +{ + public override void Configure() + { + Get("/api/calendar-integrations/sources"); + Options(o => o.WithTags("Calendar Integrations")); + } + + public override async Task HandleAsync(ListCalendarSourcesRequest request, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(request); + + Guid currentUserId = User.GetUserId(); + List sources; + + if (request.WorkspaceId.HasValue) + { + var workspace = await dbContext.Workspaces + .Where(candidate => candidate.Id == request.WorkspaceId.Value) + .Select(candidate => new { candidate.Id, candidate.OrganizationId }) + .SingleOrDefaultAsync(ct); + + if (workspace is null) + { + await SendNotFoundAsync(ct); + return; + } + + if (!await accessScopeService.CanAccessWorkspaceAsync(User, workspace.Id, ct)) + { + await SendForbiddenAsync(ct); + return; + } + + sources = await dbContext.CalendarSources + .Where(source => + source.Scope == CalendarSourceScopes.Organization && source.OrganizationId == workspace.OrganizationId || + source.Scope == CalendarSourceScopes.Workspace && source.WorkspaceId == workspace.Id || + source.Scope == CalendarSourceScopes.User && source.UserId == currentUserId) + .OrderBy(source => source.Scope) + .ThenBy(source => source.DisplayTitle) + .ToListAsync(ct); + + await SendOkAsync( + sources + .Select(source => CalendarSourceDto.FromSource( + source, + CalendarSourceRules.IsInheritedOrganizationSource(source, workspace.OrganizationId))) + .ToArray(), + ct); + return; + } + + sources = await dbContext.CalendarSources + .Where(source => source.Scope == CalendarSourceScopes.User && source.UserId == currentUserId) + .OrderBy(source => source.DisplayTitle) + .ToListAsync(ct); + + await SendOkAsync( + sources.Select(source => CalendarSourceDto.FromSource(source, isReadOnly: false)).ToArray(), + ct); + } +} diff --git a/backend/src/Socialize.Api/Modules/CalendarIntegrations/Handlers/RefreshCalendarSource.cs b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Handlers/RefreshCalendarSource.cs new file mode 100644 index 0000000..0ac632e --- /dev/null +++ b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Handlers/RefreshCalendarSource.cs @@ -0,0 +1,65 @@ +using FastEndpoints; +using Microsoft.EntityFrameworkCore; +using Socialize.Api.Data; +using Socialize.Api.Infrastructure.Security; +using Socialize.Api.Modules.CalendarIntegrations.Data; +using Socialize.Api.Modules.CalendarIntegrations.Services; +using Socialize.Api.Modules.Organizations.Services; + +namespace Socialize.Api.Modules.CalendarIntegrations.Handlers; + +public class RefreshCalendarSourceHandler( + AppDbContext dbContext, + AccessScopeService accessScopeService, + OrganizationAccessService organizationAccessService, + CalendarImportSyncService syncService) + : EndpointWithoutRequest +{ + public override void Configure() + { + Post("/api/calendar-integrations/sources/{sourceId:guid}/refresh"); + Options(o => o.WithTags("Calendar Integrations")); + } + + public override async Task HandleAsync(CancellationToken ct) + { + Guid sourceId = Route("sourceId"); + CalendarSource? source = await dbContext.CalendarSources.SingleOrDefaultAsync(candidate => candidate.Id == sourceId, ct); + if (source is null) + { + await SendNotFoundAsync(ct); + return; + } + + if (!await CanManageExistingSourceAsync(source, User.GetUserId(), ct)) + { + await SendForbiddenAsync(ct); + return; + } + + await syncService.RefreshSourceAsync(source.Id, ct); + await dbContext.Entry(source).ReloadAsync(ct); + + await SendOkAsync(CalendarSourceDto.FromSource(source, isReadOnly: false), ct); + } + + private async Task CanManageExistingSourceAsync( + CalendarSource source, + Guid currentUserId, + CancellationToken ct) + { + return source.Scope switch + { + CalendarSourceScopes.Organization when source.OrganizationId.HasValue => + await organizationAccessService.HasOrganizationPermissionAsync( + User, + source.OrganizationId.Value, + OrganizationPermissions.ManageConnectors, + ct), + CalendarSourceScopes.Workspace when source.WorkspaceId.HasValue => + await accessScopeService.CanManageWorkspaceAsync(User, source.WorkspaceId.Value, ct), + CalendarSourceScopes.User => source.UserId == currentUserId, + _ => false, + }; + } +} diff --git a/backend/src/Socialize.Api/Modules/CalendarIntegrations/Handlers/UpdateCalendarSource.cs b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Handlers/UpdateCalendarSource.cs new file mode 100644 index 0000000..8848e12 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Handlers/UpdateCalendarSource.cs @@ -0,0 +1,91 @@ +using FastEndpoints; +using Microsoft.EntityFrameworkCore; +using Socialize.Api.Data; +using Socialize.Api.Infrastructure.Security; +using Socialize.Api.Modules.CalendarIntegrations.Data; +using Socialize.Api.Modules.CalendarIntegrations.Services; +using Socialize.Api.Modules.Organizations.Services; + +namespace Socialize.Api.Modules.CalendarIntegrations.Handlers; + +public class UpdateCalendarSourceHandler( + AppDbContext dbContext, + AccessScopeService accessScopeService, + OrganizationAccessService organizationAccessService) + : Endpoint +{ + public override void Configure() + { + Put("/api/calendar-integrations/sources/{sourceId:guid}"); + Options(o => o.WithTags("Calendar Integrations")); + } + + public override async Task HandleAsync(UpsertCalendarSourceRequest request, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(request); + + Guid sourceId = Route("sourceId"); + CalendarSource? source = await dbContext.CalendarSources.SingleOrDefaultAsync(candidate => candidate.Id == sourceId, ct); + if (source is null) + { + await SendNotFoundAsync(ct); + return; + } + + Guid currentUserId = User.GetUserId(); + if (!await CanManageExistingSourceAsync(source, currentUserId, ct)) + { + await SendForbiddenAsync(ct); + return; + } + + if (source.Scope != request.Scope.Trim() || + source.OrganizationId != (request.Scope == CalendarSourceScopes.Organization ? request.OrganizationId : null) || + source.WorkspaceId != (request.Scope == CalendarSourceScopes.Workspace ? request.WorkspaceId : null)) + { + AddError("Calendar source scope cannot be changed."); + await SendErrorsAsync(StatusCodes.Status409Conflict, ct); + return; + } + + source.SourceUrl = NormalizeOptional(request.SourceUrl); + source.CatalogSourceReference = NormalizeOptional(request.CatalogSourceReference); + source.DisplayTitle = request.DisplayTitle.Trim(); + source.Color = request.Color.Trim(); + source.Category = request.Category.Trim(); + source.IsEnabled = request.IsEnabled; + source.InheritanceMode = source.Scope == CalendarSourceScopes.Organization + ? NormalizeOptional(request.InheritanceMode) ?? CalendarSourceInheritanceModes.Optional + : null; + source.UpdatedAt = DateTimeOffset.UtcNow; + + await dbContext.SaveChangesAsync(ct); + + await SendOkAsync(CalendarSourceDto.FromSource(source, isReadOnly: false), ct); + } + + private async Task CanManageExistingSourceAsync( + CalendarSource source, + Guid currentUserId, + CancellationToken ct) + { + return source.Scope switch + { + CalendarSourceScopes.Organization when source.OrganizationId.HasValue => + await organizationAccessService.HasOrganizationPermissionAsync( + User, + source.OrganizationId.Value, + OrganizationPermissions.ManageConnectors, + ct), + CalendarSourceScopes.Workspace when source.WorkspaceId.HasValue => + await accessScopeService.CanManageWorkspaceAsync(User, source.WorkspaceId.Value, ct), + CalendarSourceScopes.User => source.UserId == currentUserId, + _ => false, + }; + } + + private static string? NormalizeOptional(string? value) + { + return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } +} diff --git a/backend/src/Socialize.Api/Modules/CalendarIntegrations/Handlers/UserCalendarExportFeed.cs b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Handlers/UserCalendarExportFeed.cs new file mode 100644 index 0000000..fd9f258 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Handlers/UserCalendarExportFeed.cs @@ -0,0 +1,224 @@ +using FastEndpoints; +using Microsoft.EntityFrameworkCore; +using Socialize.Api.Data; +using Socialize.Api.Infrastructure.Security; +using Socialize.Api.Modules.CalendarIntegrations.Data; +using Socialize.Api.Modules.CalendarIntegrations.Services; +using Socialize.Api.Modules.Identity.Data; + +namespace Socialize.Api.Modules.CalendarIntegrations.Handlers; + +public record UserCalendarExportFeedDto( + bool IsEnabled, + string? FeedUrl, + DateTimeOffset? CreatedAt, + DateTimeOffset? UpdatedAt, + DateTimeOffset? RevokedAt); + +public class GetUserCalendarExportFeedHandler(AppDbContext dbContext) + : EndpointWithoutRequest +{ + public override void Configure() + { + Get("/api/calendar-integrations/export-feed"); + Options(o => o.WithTags("Calendar Integrations")); + } + + public override async Task HandleAsync(CancellationToken ct) + { + UserCalendarExportFeed? feed = await dbContext.UserCalendarExportFeeds + .SingleOrDefaultAsync(candidate => candidate.UserId == User.GetUserId(), ct); + + await SendAsync(UserCalendarExportFeedMapper.ToDto(feed, UserCalendarExportFeedMapper.BuildFeedUrl(feed)), cancellation: ct); + } +} + +public class EnableUserCalendarExportFeedHandler(AppDbContext dbContext) + : EndpointWithoutRequest +{ + public override void Configure() + { + Post("/api/calendar-integrations/export-feed/enable"); + Options(o => o.WithTags("Calendar Integrations")); + } + + public override async Task HandleAsync(CancellationToken ct) + { + Guid userId = User.GetUserId(); + string token = CalendarExportFeedTokenService.GenerateToken(); + string tokenHash = CalendarExportFeedTokenService.HashToken(token); + DateTimeOffset now = DateTimeOffset.UtcNow; + + UserCalendarExportFeed? feed = await dbContext.UserCalendarExportFeeds + .SingleOrDefaultAsync(candidate => candidate.UserId == userId, ct); + + if (feed is null) + { + feed = new UserCalendarExportFeed + { + Id = Guid.NewGuid(), + UserId = userId, + Token = token, + TokenHash = tokenHash, + UpdatedAt = now, + }; + dbContext.UserCalendarExportFeeds.Add(feed); + } + else if (feed.TokenHash is null || feed.RevokedAt.HasValue) + { + feed.Token = token; + feed.TokenHash = tokenHash; + feed.RevokedAt = null; + feed.UpdatedAt = now; + } + else + { + token = string.Empty; + } + + await dbContext.SaveChangesAsync(ct); + await SendAsync(UserCalendarExportFeedMapper.ToDto(feed, UserCalendarExportFeedMapper.BuildFeedUrl(feed, token)), cancellation: ct); + } +} + +public class RegenerateUserCalendarExportFeedHandler(AppDbContext dbContext) + : EndpointWithoutRequest +{ + public override void Configure() + { + Post("/api/calendar-integrations/export-feed/regenerate"); + Options(o => o.WithTags("Calendar Integrations")); + } + + public override async Task HandleAsync(CancellationToken ct) + { + Guid userId = User.GetUserId(); + string token = CalendarExportFeedTokenService.GenerateToken(); + DateTimeOffset now = DateTimeOffset.UtcNow; + + UserCalendarExportFeed? feed = await dbContext.UserCalendarExportFeeds + .SingleOrDefaultAsync(candidate => candidate.UserId == userId, ct); + + if (feed is null) + { + feed = new UserCalendarExportFeed + { + Id = Guid.NewGuid(), + UserId = userId, + UpdatedAt = now, + }; + dbContext.UserCalendarExportFeeds.Add(feed); + } + + feed.TokenHash = CalendarExportFeedTokenService.HashToken(token); + feed.Token = token; + feed.RevokedAt = null; + feed.UpdatedAt = now; + + await dbContext.SaveChangesAsync(ct); + await SendAsync(UserCalendarExportFeedMapper.ToDto(feed, UserCalendarExportFeedMapper.BuildFeedUrl(feed, token)), cancellation: ct); + } +} + +public class RevokeUserCalendarExportFeedHandler(AppDbContext dbContext) + : EndpointWithoutRequest +{ + public override void Configure() + { + Delete("/api/calendar-integrations/export-feed"); + Options(o => o.WithTags("Calendar Integrations")); + } + + public override async Task HandleAsync(CancellationToken ct) + { + UserCalendarExportFeed? feed = await dbContext.UserCalendarExportFeeds + .SingleOrDefaultAsync(candidate => candidate.UserId == User.GetUserId(), ct); + + if (feed is not null) + { + feed.TokenHash = null; + feed.Token = null; + feed.RevokedAt = DateTimeOffset.UtcNow; + feed.UpdatedAt = feed.RevokedAt.Value; + await dbContext.SaveChangesAsync(ct); + } + + await SendAsync(UserCalendarExportFeedMapper.ToDto(feed, null), cancellation: ct); + } +} + +public class GetUserCalendarExportFeedIcsHandler( + AppDbContext dbContext, + CalendarExportFeedService feedService) + : EndpointWithoutRequest +{ + public override void Configure() + { + AllowAnonymous(); + Get("/api/calendar-integrations/export-feed/{token}.ics"); + Options(o => o.WithTags("Calendar Integrations")); + } + + public override async Task HandleAsync(CancellationToken ct) + { + string? token = Route("token"); + if (string.IsNullOrWhiteSpace(token)) + { + await SendNotFoundAsync(ct); + return; + } + + string tokenHash = CalendarExportFeedTokenService.HashToken(token); + + UserCalendarExportFeed? feed = await dbContext.UserCalendarExportFeeds + .SingleOrDefaultAsync(candidate => + candidate.TokenHash == tokenHash && + !candidate.RevokedAt.HasValue, + ct); + + if (feed is null) + { + await SendNotFoundAsync(ct); + return; + } + + User? user = await dbContext.Users.SingleOrDefaultAsync(candidate => candidate.Id == feed.UserId, ct); + if (user is null) + { + await SendNotFoundAsync(ct); + return; + } + + string appBaseUrl = $"{HttpContext.Request.Scheme}://{HttpContext.Request.Host}"; + string ics = await feedService.BuildUserFeedAsync(feed.UserId, user.Email, appBaseUrl, ct); + + HttpContext.Response.ContentType = "text/calendar; charset=utf-8"; + await HttpContext.Response.WriteAsync(ics, ct); + } +} + +file static class UserCalendarExportFeedMapper +{ + public static UserCalendarExportFeedDto ToDto(UserCalendarExportFeed? feed, string? feedUrl) + { + return new UserCalendarExportFeedDto( + feed?.TokenHash is not null && !feed.RevokedAt.HasValue, + feedUrl, + feed?.CreatedAt, + feed?.UpdatedAt, + feed?.RevokedAt); + } + + public static string? BuildFeedUrl(UserCalendarExportFeed? feed, string? token = null) + { + if (feed?.TokenHash is null || feed.RevokedAt.HasValue) + { + return null; + } + + string effectiveToken = string.IsNullOrWhiteSpace(token) ? feed.Token ?? string.Empty : token; + return string.IsNullOrWhiteSpace(effectiveToken) + ? null + : $"/api/calendar-integrations/export-feed/{effectiveToken}.ics"; + } +} diff --git a/backend/src/Socialize.Api/Modules/CalendarIntegrations/Services/CalendarExportFeedBuilder.cs b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Services/CalendarExportFeedBuilder.cs new file mode 100644 index 0000000..1c70078 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Services/CalendarExportFeedBuilder.cs @@ -0,0 +1,80 @@ +using System.Text; + +namespace Socialize.Api.Modules.CalendarIntegrations.Services; + +public sealed record CalendarExportFeedEvent( + string Uid, + string Title, + DateTimeOffset StartsAt, + DateTimeOffset EndsAt, + bool IsAllDay, + string? Description, + string? Url); + +public class CalendarExportFeedBuilder +{ + public string Build(string calendarName, IReadOnlyCollection events) + { + StringBuilder builder = new(); + builder.AppendLine("BEGIN:VCALENDAR"); + builder.AppendLine("VERSION:2.0"); + builder.AppendLine("PRODID:-//Socialize//User Work Calendar//EN"); + builder.AppendLine("CALSCALE:GREGORIAN"); + builder.AppendLine("METHOD:PUBLISH"); + builder.AppendLine($"X-WR-CALNAME:{EscapeText(calendarName)}"); + + foreach (CalendarExportFeedEvent feedEvent in events.OrderBy(calendarEvent => calendarEvent.StartsAt)) + { + builder.AppendLine("BEGIN:VEVENT"); + builder.AppendLine($"UID:{EscapeText(feedEvent.Uid)}"); + builder.AppendLine($"DTSTAMP:{FormatUtc(DateTimeOffset.UtcNow)}"); + builder.AppendLine($"SUMMARY:{EscapeText(feedEvent.Title)}"); + + if (feedEvent.IsAllDay) + { + builder.AppendLine($"DTSTART;VALUE=DATE:{FormatDate(feedEvent.StartsAt)}"); + builder.AppendLine($"DTEND;VALUE=DATE:{FormatDate(feedEvent.EndsAt)}"); + } + else + { + builder.AppendLine($"DTSTART:{FormatUtc(feedEvent.StartsAt)}"); + builder.AppendLine($"DTEND:{FormatUtc(feedEvent.EndsAt)}"); + } + + if (!string.IsNullOrWhiteSpace(feedEvent.Description)) + { + builder.AppendLine($"DESCRIPTION:{EscapeText(feedEvent.Description)}"); + } + + if (!string.IsNullOrWhiteSpace(feedEvent.Url)) + { + builder.AppendLine($"URL:{EscapeText(feedEvent.Url)}"); + } + + builder.AppendLine("END:VEVENT"); + } + + builder.AppendLine("END:VCALENDAR"); + return builder.ToString(); + } + + private static string FormatDate(DateTimeOffset value) + { + return value.ToString("yyyyMMdd", System.Globalization.CultureInfo.InvariantCulture); + } + + private static string FormatUtc(DateTimeOffset value) + { + return value.UtcDateTime.ToString("yyyyMMdd'T'HHmmss'Z'", System.Globalization.CultureInfo.InvariantCulture); + } + + private static string EscapeText(string value) + { + return value + .Replace("\\", "\\\\") + .Replace("\r\n", "\\n") + .Replace("\n", "\\n") + .Replace(";", "\\;") + .Replace(",", "\\,"); + } +} diff --git a/backend/src/Socialize.Api/Modules/CalendarIntegrations/Services/CalendarExportFeedService.cs b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Services/CalendarExportFeedService.cs new file mode 100644 index 0000000..c97a259 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Services/CalendarExportFeedService.cs @@ -0,0 +1,173 @@ +using Microsoft.EntityFrameworkCore; +using Socialize.Api.Data; + +namespace Socialize.Api.Modules.CalendarIntegrations.Services; + +public class CalendarExportFeedService(AppDbContext dbContext, CalendarExportFeedBuilder feedBuilder) +{ + public async Task BuildUserFeedAsync(Guid userId, string? userEmail, string appBaseUrl, CancellationToken ct) + { + string normalizedEmail = userEmail?.Trim().ToUpperInvariant() ?? string.Empty; + Guid[] workspaceIds = await dbContext.Workspaces + .Where(workspace => + workspace.OwnerUserId == userId || + dbContext.OrganizationMemberships.Any(membership => + membership.OrganizationId == workspace.OrganizationId && + membership.UserId == userId)) + .Select(workspace => workspace.Id) + .ToArrayAsync(ct); + + List events = []; + + events.AddRange(await dbContext.ContentItems + .Where(item => workspaceIds.Contains(item.WorkspaceId) && item.DueDate.HasValue) + .Join( + dbContext.Workspaces, + item => item.WorkspaceId, + workspace => workspace.Id, + (item, workspace) => new { item, workspace }) + .Join( + dbContext.Clients, + itemWorkspace => itemWorkspace.item.ClientId, + client => client.Id, + (itemWorkspace, client) => new { itemWorkspace.item, itemWorkspace.workspace, client }) + .Join( + dbContext.Campaigns, + itemWorkspaceClient => itemWorkspaceClient.item.CampaignId, + campaign => campaign.Id, + (itemWorkspaceClient, campaign) => new { itemWorkspaceClient.item, itemWorkspaceClient.workspace, itemWorkspaceClient.client, campaign }) + .Select(candidate => ToContentFeedEvent( + candidate.item.Id, + candidate.item.Title, + candidate.item.Status, + candidate.item.DueDate!.Value, + candidate.workspace.Name, + candidate.client.Name, + candidate.campaign.Name, + appBaseUrl)) + .ToListAsync(ct)); + + events.AddRange(await dbContext.ApprovalRequests + .Where(approval => + approval.DueAt.HasValue && + (approval.RequestedByUserId == userId || + (!string.IsNullOrEmpty(normalizedEmail) && approval.ReviewerEmail.ToUpper() == normalizedEmail))) + .Join( + dbContext.ContentItems, + approval => approval.ContentItemId, + item => item.Id, + (approval, item) => new { approval, item }) + .Where(candidate => workspaceIds.Contains(candidate.approval.WorkspaceId)) + .Join( + dbContext.Workspaces, + approvalItem => approvalItem.approval.WorkspaceId, + workspace => workspace.Id, + (approvalItem, workspace) => new { approvalItem.approval, approvalItem.item, workspace }) + .Select(candidate => ToApprovalFeedEvent( + candidate.approval.Id, + candidate.item.Id, + candidate.item.Title, + candidate.approval.Stage, + candidate.approval.State, + candidate.approval.DueAt!.Value, + candidate.workspace.Name, + appBaseUrl)) + .ToListAsync(ct)); + + events.AddRange(await dbContext.Campaigns + .Where(campaign => workspaceIds.Contains(campaign.WorkspaceId)) + .Join( + dbContext.Workspaces, + campaign => campaign.WorkspaceId, + workspace => workspace.Id, + (campaign, workspace) => new { campaign, workspace }) + .Select(candidate => ToCampaignFeedEvent( + candidate.campaign.Id, + candidate.campaign.Name, + candidate.campaign.Status, + candidate.campaign.StartDate, + candidate.campaign.EndDate, + candidate.workspace.Name, + appBaseUrl)) + .ToListAsync(ct)); + + return feedBuilder.Build("Socialize my work", events); + } + + private static CalendarExportFeedEvent ToContentFeedEvent( + Guid contentItemId, + string title, + string status, + DateTimeOffset dueDate, + string workspaceName, + string clientName, + string campaignName, + string appBaseUrl) + { + (DateTimeOffset start, DateTimeOffset end, bool isAllDay) = NormalizeEventTime(dueDate); + + return new CalendarExportFeedEvent( + $"content-{contentItemId}@socialize", + title, + start, + end, + isAllDay, + $"Status: {status}\nWorkspace: {workspaceName}\nClient: {clientName}\nCampaign: {campaignName}", + $"{appBaseUrl.TrimEnd('/')}/app/content/{contentItemId}"); + } + + private static CalendarExportFeedEvent ToApprovalFeedEvent( + Guid approvalId, + Guid contentItemId, + string contentTitle, + string stage, + string state, + DateTimeOffset dueAt, + string workspaceName, + string appBaseUrl) + { + (DateTimeOffset start, DateTimeOffset end, bool isAllDay) = NormalizeEventTime(dueAt); + + return new CalendarExportFeedEvent( + $"approval-{approvalId}@socialize", + $"Approval due: {contentTitle}", + start, + end, + isAllDay, + $"Stage: {stage}\nState: {state}\nWorkspace: {workspaceName}", + $"{appBaseUrl.TrimEnd('/')}/app/content/{contentItemId}"); + } + + private static CalendarExportFeedEvent ToCampaignFeedEvent( + Guid campaignId, + string name, + string status, + DateTimeOffset startDate, + DateTimeOffset endDate, + string workspaceName, + string appBaseUrl) + { + DateTimeOffset start = new(startDate.Date, startDate.Offset); + DateTimeOffset end = new(endDate.Date.AddDays(1), endDate.Offset); + + return new CalendarExportFeedEvent( + $"campaign-{campaignId}@socialize", + $"Campaign: {name}", + start, + end <= start ? start.AddDays(1) : end, + true, + $"Status: {status}\nWorkspace: {workspaceName}", + $"{appBaseUrl.TrimEnd('/')}/app/campaigns/{campaignId}"); + } + + private static (DateTimeOffset Start, DateTimeOffset End, bool IsAllDay) NormalizeEventTime(DateTimeOffset value) + { + if (value.TimeOfDay == TimeSpan.Zero) + { + DateTimeOffset start = new(value.Date, value.Offset); + return (start, start.AddDays(1), true); + } + + return (value, value.AddMinutes(30), false); + } +} diff --git a/backend/src/Socialize.Api/Modules/CalendarIntegrations/Services/CalendarExportFeedTokenService.cs b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Services/CalendarExportFeedTokenService.cs new file mode 100644 index 0000000..d3d9fb2 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Services/CalendarExportFeedTokenService.cs @@ -0,0 +1,27 @@ +using System.Security.Cryptography; + +namespace Socialize.Api.Modules.CalendarIntegrations.Services; + +public static class CalendarExportFeedTokenService +{ + public static string GenerateToken() + { + Span bytes = stackalloc byte[32]; + RandomNumberGenerator.Fill(bytes); + return Base64UrlEncode(bytes); + } + + public static string HashToken(string token) + { + byte[] bytes = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(token)); + return Convert.ToHexString(bytes); + } + + private static string Base64UrlEncode(ReadOnlySpan bytes) + { + return Convert.ToBase64String(bytes) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } +} diff --git a/backend/src/Socialize.Api/Modules/CalendarIntegrations/Services/CalendarImportBackgroundService.cs b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Services/CalendarImportBackgroundService.cs new file mode 100644 index 0000000..68e8395 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Services/CalendarImportBackgroundService.cs @@ -0,0 +1,34 @@ +namespace Socialize.Api.Modules.CalendarIntegrations.Services; + +public sealed class CalendarImportBackgroundService( + IServiceScopeFactory scopeFactory, + ILogger logger) + : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + using PeriodicTimer timer = new(TimeSpan.FromHours(6)); + while (!stoppingToken.IsCancellationRequested) + { + await RefreshDueSourcesAsync(stoppingToken); + await timer.WaitForNextTickAsync(stoppingToken); + } + } + + private async Task RefreshDueSourcesAsync(CancellationToken stoppingToken) + { + try + { + using IServiceScope scope = scopeFactory.CreateScope(); + CalendarImportSyncService syncService = scope.ServiceProvider.GetRequiredService(); + await syncService.RefreshDueSourcesAsync(stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + } + catch (Exception ex) + { + logger.LogError(ex, "Calendar import background sync failed."); + } + } +} diff --git a/backend/src/Socialize.Api/Modules/CalendarIntegrations/Services/CalendarImportSyncService.cs b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Services/CalendarImportSyncService.cs new file mode 100644 index 0000000..70bf491 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Services/CalendarImportSyncService.cs @@ -0,0 +1,313 @@ +using Microsoft.EntityFrameworkCore; +using Socialize.Api.Data; +using Socialize.Api.Modules.CalendarIntegrations.Data; +using System.Text.Json; + +namespace Socialize.Api.Modules.CalendarIntegrations.Services; + +public sealed class CalendarImportSyncService( + AppDbContext dbContext, + IHttpClientFactory httpClientFactory, + IcsCalendarParser parser) +{ + public async Task RefreshSourceAsync(Guid sourceId, CancellationToken ct) + { + CalendarSource? source = await dbContext.CalendarSources + .SingleOrDefaultAsync(candidate => candidate.Id == sourceId, ct); + if (source is null) + { + throw new InvalidOperationException("Calendar source was not found."); + } + + source.LastAttemptedSyncAt = DateTimeOffset.UtcNow; + + if (string.IsNullOrWhiteSpace(source.SourceUrl)) + { + source.LastSyncError = "Calendar source does not have a source URL."; + await dbContext.SaveChangesAsync(ct); + return; + } + + try + { + using HttpClient httpClient = httpClientFactory.CreateClient(); + DateOnly rangeStart = DateOnly.FromDateTime(DateTime.UtcNow.AddYears(-1)); + DateOnly rangeEnd = DateOnly.FromDateTime(DateTime.UtcNow.AddYears(2)); + IReadOnlyCollection parsedEvents = await GetParsedEventsAsync( + httpClient, + source.SourceUrl, + rangeStart, + rangeEnd, + ct); + + await ReplaceEventsAsync(source.Id, parsedEvents, ct); + + source.LastSuccessfulSyncAt = DateTimeOffset.UtcNow; + source.LastSyncError = null; + source.LastAttemptedSyncAt = source.LastSuccessfulSyncAt; + await dbContext.SaveChangesAsync(ct); + } + catch (HttpRequestException ex) + { + await RecordSyncFailureAsync(source, ex.Message, ct); + } + catch (FormatException ex) + { + await RecordSyncFailureAsync(source, ex.Message, ct); + } + catch (InvalidOperationException ex) + { + await RecordSyncFailureAsync(source, ex.Message, ct); + } + } + + public async Task RefreshDueSourcesAsync(CancellationToken ct) + { + DateTimeOffset staleBefore = DateTimeOffset.UtcNow.AddHours(-12); + Guid[] sourceIds = await dbContext.CalendarSources + .Where(source => source.IsEnabled && source.SourceUrl != null) + .Where(source => source.LastAttemptedSyncAt == null || source.LastAttemptedSyncAt < staleBefore) + .OrderBy(source => source.LastAttemptedSyncAt) + .Select(source => source.Id) + .Take(25) + .ToArrayAsync(ct); + + foreach (Guid sourceId in sourceIds) + { + await RefreshSourceAsync(sourceId, ct); + } + } + + private async Task ReplaceEventsAsync( + Guid sourceId, + IReadOnlyCollection parsedEvents, + CancellationToken ct) + { + await dbContext.CalendarEvents + .Where(calendarEvent => calendarEvent.CalendarSourceId == sourceId) + .ExecuteDeleteAsync(ct); + + DateTimeOffset importedAt = DateTimeOffset.UtcNow; + foreach (ParsedCalendarEvent parsedEvent in parsedEvents) + { + dbContext.CalendarEvents.Add(new CalendarEvent + { + Id = Guid.NewGuid(), + CalendarSourceId = sourceId, + SourceEventUid = parsedEvent.SourceEventUid, + Title = parsedEvent.Title, + Description = parsedEvent.Description, + IsAllDay = parsedEvent.IsAllDay, + IsFloatingTime = parsedEvent.IsFloatingTime, + StartDate = parsedEvent.StartDate, + EndDate = parsedEvent.EndDate, + StartLocalDateTime = parsedEvent.StartLocalDateTime, + EndLocalDateTime = parsedEvent.EndLocalDateTime, + StartUtc = parsedEvent.StartUtc, + EndUtc = parsedEvent.EndUtc, + TimeZoneId = parsedEvent.TimeZoneId, + RecurrenceId = parsedEvent.RecurrenceId, + Location = parsedEvent.Location, + SourceUrl = parsedEvent.SourceUrl, + SourceLastModifiedAt = parsedEvent.SourceLastModifiedAt, + ImportedAt = importedAt, + }); + } + } + + private async Task> GetParsedEventsAsync( + HttpClient httpClient, + string sourceUrl, + DateOnly rangeStart, + DateOnly rangeEnd, + CancellationToken ct) + { + if (TryGetNagerCountryCode(sourceUrl, out string? countryCode)) + { + return await GetNagerEventsAsync(httpClient, sourceUrl, countryCode!, rangeStart, rangeEnd, ct); + } + + string content = await httpClient.GetStringAsync(sourceUrl, ct); + return parser.Parse(content, rangeStart, rangeEnd); + } + + private static async Task> GetNagerEventsAsync( + HttpClient httpClient, + string sourceUrl, + string countryCode, + DateOnly rangeStart, + DateOnly rangeEnd, + CancellationToken ct) + { + List events = []; + for (int year = rangeStart.Year; year <= rangeEnd.Year; year++) + { + string yearUrl = BuildNagerYearUrl(sourceUrl, countryCode, year); + string json = await httpClient.GetStringAsync(yearUrl, ct); + NagerHoliday[] holidays = JsonSerializer.Deserialize( + json, + new JsonSerializerOptions(JsonSerializerDefaults.Web)) ?? []; + + foreach (NagerHoliday holiday in holidays) + { + if (!DateOnly.TryParse(holiday.Date, out DateOnly date) || + date < rangeStart || + date > rangeEnd) + { + continue; + } + + events.Add(ToParsedEvent( + $"nager-{countryCode}-{date:yyyyMMdd}-{NormalizeUidPart(holiday.Name)}", + string.IsNullOrWhiteSpace(holiday.Name) ? holiday.LocalName : holiday.Name, + holiday.LocalName, + date, + string.Join(", ", holiday.Types ?? []), + yearUrl)); + } + + events.AddRange(GetSupplementalCountryEvents(countryCode, year, rangeStart, rangeEnd)); + } + + return events + .GroupBy(calendarEvent => calendarEvent.SourceEventUid) + .Select(group => group.First()) + .OrderBy(calendarEvent => calendarEvent.StartDate) + .ThenBy(calendarEvent => calendarEvent.Title) + .ToArray(); + } + + private static ParsedCalendarEvent ToParsedEvent( + string uid, + string? title, + string? localName, + DateOnly date, + string? types, + string sourceUrl) + { + string? description = string.IsNullOrWhiteSpace(types) + ? localName + : $"{localName}\nTypes: {types}"; + + return new ParsedCalendarEvent( + uid, + string.IsNullOrWhiteSpace(title) ? "Untitled event" : title, + description, + IsAllDay: true, + IsFloatingTime: false, + date, + date.AddDays(1), + StartLocalDateTime: null, + EndLocalDateTime: null, + StartUtc: null, + EndUtc: null, + TimeZoneId: null, + RecurrenceId: null, + Location: null, + sourceUrl, + SourceLastModifiedAt: null); + } + + private static IReadOnlyCollection GetSupplementalCountryEvents( + string countryCode, + int year, + DateOnly rangeStart, + DateOnly rangeEnd) + { + if (!countryCode.Equals("CA", StringComparison.OrdinalIgnoreCase)) + { + return []; + } + + DateOnly mothersDay = NthWeekdayOfMonth(year, month: 5, DayOfWeek.Sunday, occurrence: 2); + if (mothersDay < rangeStart || mothersDay > rangeEnd) + { + return []; + } + + return + [ + ToParsedEvent( + $"socialize-ca-mothers-day-{year}", + "Mother's Day", + "Mother's Day", + mothersDay, + "Observance", + "socialize://calendar-observances/CA"), + ]; + } + + private static DateOnly NthWeekdayOfMonth(int year, int month, DayOfWeek dayOfWeek, int occurrence) + { + DateOnly date = new(year, month, 1); + while (date.DayOfWeek != dayOfWeek) + { + date = date.AddDays(1); + } + + return date.AddDays((occurrence - 1) * 7); + } + + private static bool TryGetNagerCountryCode(string sourceUrl, out string? countryCode) + { + countryCode = null; + if (!Uri.TryCreate(sourceUrl, UriKind.Absolute, out Uri? uri) || + !uri.Host.Contains("date.nager.at", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + string[] segments = uri.AbsolutePath + .Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + string? candidate = segments.LastOrDefault(segment => segment.Length == 2); + if (candidate is null) + { + return false; + } + + countryCode = candidate.ToUpperInvariant(); + return true; + } + + private static string BuildNagerYearUrl(string sourceUrl, string countryCode, int year) + { + if (Uri.TryCreate(sourceUrl, UriKind.Absolute, out Uri? uri)) + { + return $"{uri.Scheme}://{uri.Host}/api/v3/PublicHolidays/{year}/{countryCode}"; + } + + return $"https://date.nager.at/api/v3/PublicHolidays/{year}/{countryCode}"; + } + + private static string NormalizeUidPart(string? value) + { + return new string((value ?? "holiday") + .ToLowerInvariant() + .Select(character => char.IsLetterOrDigit(character) ? character : '-') + .ToArray()) + .Trim('-'); + } + + private async Task RecordSyncFailureAsync( + CalendarSource source, + string message, + CancellationToken ct) + { + source.LastSyncError = NormalizeSyncError(message); + await dbContext.SaveChangesAsync(ct); + } + + public static string NormalizeSyncError(string message) + { + ArgumentNullException.ThrowIfNull(message); + + return message.Length > 2048 ? message[..2048] : message; + } + + private sealed record NagerHoliday( + string Date, + string LocalName, + string Name, + string[]? Types); +} diff --git a/backend/src/Socialize.Api/Modules/CalendarIntegrations/Services/CalendarSourceConstants.cs b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Services/CalendarSourceConstants.cs new file mode 100644 index 0000000..d715985 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Services/CalendarSourceConstants.cs @@ -0,0 +1,14 @@ +namespace Socialize.Api.Modules.CalendarIntegrations.Services; + +public static class CalendarSourceScopes +{ + public const string Organization = "Organization"; + public const string Workspace = "Workspace"; + public const string User = "User"; +} + +public static class CalendarSourceInheritanceModes +{ + public const string Required = "Required"; + public const string Optional = "Optional"; +} diff --git a/backend/src/Socialize.Api/Modules/CalendarIntegrations/Services/CalendarSourceRules.cs b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Services/CalendarSourceRules.cs new file mode 100644 index 0000000..e985d92 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Services/CalendarSourceRules.cs @@ -0,0 +1,51 @@ +using Socialize.Api.Modules.CalendarIntegrations.Data; + +namespace Socialize.Api.Modules.CalendarIntegrations.Services; + +public static class CalendarSourceRules +{ + public static readonly string[] SupportedScopes = + [ + CalendarSourceScopes.Organization, + CalendarSourceScopes.Workspace, + CalendarSourceScopes.User, + ]; + + public static readonly string[] SupportedInheritanceModes = + [ + CalendarSourceInheritanceModes.Required, + CalendarSourceInheritanceModes.Optional, + ]; + + public static bool IsSupportedScope(string? scope) + { + return SupportedScopes.Contains(scope?.Trim(), StringComparer.Ordinal); + } + + public static bool IsSupportedInheritanceMode(string? inheritanceMode) + { + return SupportedInheritanceModes.Contains(inheritanceMode?.Trim(), StringComparer.Ordinal); + } + + public static bool IsInheritedOrganizationSource(CalendarSource source, Guid workspaceOrganizationId) + { + return source.Scope == CalendarSourceScopes.Organization && + source.OrganizationId == workspaceOrganizationId; + } + + public static bool CanManageScope( + string scope, + bool canManageOrganizationCalendars, + bool canManageWorkspaceCalendars, + Guid currentUserId, + Guid? sourceUserId) + { + return scope switch + { + CalendarSourceScopes.Organization => canManageOrganizationCalendars, + CalendarSourceScopes.Workspace => canManageWorkspaceCalendars, + CalendarSourceScopes.User => sourceUserId == currentUserId, + _ => false, + }; + } +} diff --git a/backend/src/Socialize.Api/Modules/CalendarIntegrations/Services/IcsCalendarParser.cs b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Services/IcsCalendarParser.cs new file mode 100644 index 0000000..953ee29 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Services/IcsCalendarParser.cs @@ -0,0 +1,414 @@ +using System.Globalization; + +namespace Socialize.Api.Modules.CalendarIntegrations.Services; + +public record ParsedCalendarEvent( + string SourceEventUid, + string Title, + string? Description, + bool IsAllDay, + bool IsFloatingTime, + DateOnly StartDate, + DateOnly EndDate, + DateTime? StartLocalDateTime, + DateTime? EndLocalDateTime, + DateTimeOffset? StartUtc, + DateTimeOffset? EndUtc, + string? TimeZoneId, + string? RecurrenceId, + string? Location, + string? SourceUrl, + DateTimeOffset? SourceLastModifiedAt); + +internal record IcsDateTimeValue( + bool IsAllDay, + bool IsFloatingTime, + DateOnly Date, + DateTime? LocalDateTime, + DateTimeOffset? UtcDateTime, + string? TimeZoneId); + +internal sealed record IcsRawEvent( + string Uid, + string Title, + string? Description, + IcsDateTimeValue Start, + IcsDateTimeValue? End, + string? RRule, + string? Location, + string? SourceUrl, + DateTimeOffset? LastModifiedAt); + +public sealed class IcsCalendarParser +{ + public IReadOnlyCollection Parse( + string content, + DateOnly rangeStart, + DateOnly rangeEnd) + { + ArgumentNullException.ThrowIfNull(content); + + List events = []; + foreach (IcsRawEvent rawEvent in ReadRawEvents(content)) + { + events.AddRange(Expand(rawEvent, rangeStart, rangeEnd)); + } + + return events + .OrderBy(calendarEvent => calendarEvent.StartDate) + .ThenBy(calendarEvent => calendarEvent.Title) + .ToArray(); + } + + private static IEnumerable ReadRawEvents(string content) + { + List lines = UnfoldLines(content).ToList(); + for (int index = 0; index < lines.Count; index++) + { + if (!lines[index].Equals("BEGIN:VEVENT", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + Dictionary Parameters, string Value)>> properties = + new(StringComparer.OrdinalIgnoreCase); + + index++; + for (; index < lines.Count && !lines[index].Equals("END:VEVENT", StringComparison.OrdinalIgnoreCase); index++) + { + ParseProperty(lines[index], properties); + } + + if (!TryGetFirst(properties, "DTSTART", out var startProperty)) + { + continue; + } + + IcsDateTimeValue start = ParseDateTimeValue(startProperty.Value, startProperty.Parameters); + IcsDateTimeValue? end = TryGetFirst(properties, "DTEND", out var endProperty) + ? ParseDateTimeValue(endProperty.Value, endProperty.Parameters) + : null; + + string uid = TryGetFirst(properties, "UID", out var uidProperty) + ? uidProperty.Value + : $"{start.Date:yyyyMMdd}:{GetText(properties, "SUMMARY") ?? "calendar-event"}"; + + yield return new IcsRawEvent( + uid, + GetText(properties, "SUMMARY") ?? "Untitled event", + GetText(properties, "DESCRIPTION"), + start, + end, + GetText(properties, "RRULE"), + GetText(properties, "LOCATION"), + GetText(properties, "URL"), + TryGetFirst(properties, "LAST-MODIFIED", out var lastModified) + ? ParseDateTimeValue(lastModified.Value, lastModified.Parameters).UtcDateTime + : null); + } + } + + private static IEnumerable UnfoldLines(string content) + { + string? current = null; + using StringReader reader = new(content.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n')); + while (reader.ReadLine() is { } line) + { + if ((line.StartsWith(' ') || line.StartsWith('\t')) && current is not null) + { + current += line[1..]; + continue; + } + + if (current is not null) + { + yield return current; + } + + current = line; + } + + if (current is not null) + { + yield return current; + } + } + + private static void ParseProperty( + string line, + Dictionary Parameters, string Value)>> properties) + { + int separatorIndex = line.IndexOf(':', StringComparison.Ordinal); + if (separatorIndex < 0) + { + return; + } + + string nameAndParameters = line[..separatorIndex]; + string value = UnescapeText(line[(separatorIndex + 1)..]); + string[] nameParts = nameAndParameters.Split(';'); + string name = nameParts[0]; + Dictionary parameters = new(StringComparer.OrdinalIgnoreCase); + foreach (string parameterPart in nameParts.Skip(1)) + { + int equalsIndex = parameterPart.IndexOf('=', StringComparison.Ordinal); + if (equalsIndex > 0) + { + parameters[parameterPart[..equalsIndex]] = parameterPart[(equalsIndex + 1)..].Trim('"'); + } + } + + if (!properties.TryGetValue(name, out var values)) + { + values = []; + properties[name] = values; + } + + values.Add((parameters, value)); + } + + private static string UnescapeText(string value) + { + return value + .Replace("\\n", "\n", StringComparison.OrdinalIgnoreCase) + .Replace("\\,", ",", StringComparison.Ordinal) + .Replace("\\;", ";", StringComparison.Ordinal) + .Replace("\\\\", "\\", StringComparison.Ordinal); + } + + private static bool TryGetFirst( + Dictionary Parameters, string Value)>> properties, + string key, + out (Dictionary Parameters, string Value) value) + { + if (properties.TryGetValue(key, out var values) && values.Count > 0) + { + value = values[0]; + return true; + } + + value = default; + return false; + } + + private static string? GetText( + Dictionary Parameters, string Value)>> properties, + string key) + { + return TryGetFirst(properties, key, out var value) ? value.Value : null; + } + + private static IcsDateTimeValue ParseDateTimeValue( + string value, + Dictionary parameters) + { + bool isAllDay = parameters.TryGetValue("VALUE", out string? valueType) && + valueType.Equals("DATE", StringComparison.OrdinalIgnoreCase); + + if (isAllDay || value.Length == 8) + { + DateOnly date = DateOnly.ParseExact(value, "yyyyMMdd", CultureInfo.InvariantCulture); + return new IcsDateTimeValue(true, false, date, null, null, null); + } + + bool utc = value.EndsWith('Z'); + string parseValue = utc ? value[..^1] : value; + DateTime local = DateTime.ParseExact(parseValue, "yyyyMMdd'T'HHmmss", CultureInfo.InvariantCulture); + if (utc) + { + DateTimeOffset utcValue = new(DateTime.SpecifyKind(local, DateTimeKind.Utc)); + return new IcsDateTimeValue(false, false, DateOnly.FromDateTime(local), local, utcValue, "UTC"); + } + + string? timeZoneId = parameters.GetValueOrDefault("TZID"); + bool floating = string.IsNullOrWhiteSpace(timeZoneId); + return new IcsDateTimeValue( + false, + floating, + DateOnly.FromDateTime(local), + local, + TryConvertToUtc(local, timeZoneId), + timeZoneId); + } + + private static DateTimeOffset? TryConvertToUtc(DateTime localDateTime, string? timeZoneId) + { + if (string.IsNullOrWhiteSpace(timeZoneId)) + { + return null; + } + + try + { + TimeZoneInfo timeZone = TimeZoneInfo.FindSystemTimeZoneById(timeZoneId); + DateTime unspecified = DateTime.SpecifyKind(localDateTime, DateTimeKind.Unspecified); + return TimeZoneInfo.ConvertTimeToUtc(unspecified, timeZone); + } + catch (TimeZoneNotFoundException) + { + return null; + } + catch (InvalidTimeZoneException) + { + return null; + } + } + + private static IEnumerable Expand( + IcsRawEvent rawEvent, + DateOnly rangeStart, + DateOnly rangeEnd) + { + TimeSpan duration = GetDuration(rawEvent.Start, rawEvent.End); + IReadOnlyCollection starts = ExpandStartDates(rawEvent, rangeStart, rangeEnd); + foreach (DateOnly startDate in starts) + { + int dayOffset = startDate.DayNumber - rawEvent.Start.Date.DayNumber; + IcsDateTimeValue occurrenceStart = Shift(rawEvent.Start, dayOffset); + IcsDateTimeValue occurrenceEnd = rawEvent.End is null + ? ShiftByDuration(occurrenceStart, duration) + : Shift(rawEvent.End, dayOffset); + + yield return new ParsedCalendarEvent( + rawEvent.Uid, + rawEvent.Title, + rawEvent.Description, + occurrenceStart.IsAllDay, + occurrenceStart.IsFloatingTime, + occurrenceStart.Date, + occurrenceEnd.Date, + occurrenceStart.LocalDateTime, + occurrenceEnd.LocalDateTime, + occurrenceStart.UtcDateTime, + occurrenceEnd.UtcDateTime, + occurrenceStart.TimeZoneId, + rawEvent.RRule is null ? null : rawEvent.Uid, + rawEvent.Location, + rawEvent.SourceUrl, + rawEvent.LastModifiedAt); + } + } + + private static TimeSpan GetDuration(IcsDateTimeValue start, IcsDateTimeValue? end) + { + if (end is null) + { + return start.IsAllDay ? TimeSpan.FromDays(1) : TimeSpan.Zero; + } + + if (start.IsAllDay) + { + return TimeSpan.FromDays(Math.Max(1, end.Date.DayNumber - start.Date.DayNumber)); + } + + if (start.LocalDateTime.HasValue && end.LocalDateTime.HasValue) + { + return end.LocalDateTime.Value - start.LocalDateTime.Value; + } + + return TimeSpan.Zero; + } + + private static IReadOnlyCollection ExpandStartDates( + IcsRawEvent rawEvent, + DateOnly rangeStart, + DateOnly rangeEnd) + { + if (string.IsNullOrWhiteSpace(rawEvent.RRule)) + { + return IsInRange(rawEvent.Start.Date, rangeStart, rangeEnd) ? [rawEvent.Start.Date] : []; + } + + Dictionary rule = rawEvent.RRule + .Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(part => part.Split('=', 2)) + .Where(parts => parts.Length == 2) + .ToDictionary(parts => parts[0], parts => parts[1], StringComparer.OrdinalIgnoreCase); + + string frequency = rule.GetValueOrDefault("FREQ", "DAILY"); + int interval = int.TryParse(rule.GetValueOrDefault("INTERVAL"), out int parsedInterval) + ? Math.Max(1, parsedInterval) + : 1; + int? count = int.TryParse(rule.GetValueOrDefault("COUNT"), out int parsedCount) ? parsedCount : null; + DateOnly? until = TryParseUntil(rule.GetValueOrDefault("UNTIL")); + List dates = []; + + DateOnly current = rawEvent.Start.Date; + for (int occurrence = 1; occurrence <= (count ?? 500); occurrence++) + { + if (until.HasValue && current > until.Value) + { + break; + } + + if (current > rangeEnd) + { + break; + } + + if (IsInRange(current, rangeStart, rangeEnd)) + { + dates.Add(current); + } + + current = frequency.ToUpperInvariant() switch + { + "YEARLY" => current.AddYears(interval), + "MONTHLY" => current.AddMonths(interval), + "WEEKLY" => current.AddDays(7 * interval), + _ => current.AddDays(interval), + }; + } + + return dates; + } + + private static DateOnly? TryParseUntil(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + string dateValue = value.EndsWith('Z') ? value[..^1] : value; + if (dateValue.Length >= 8 && + DateOnly.TryParseExact(dateValue[..8], "yyyyMMdd", CultureInfo.InvariantCulture, DateTimeStyles.None, out DateOnly date)) + { + return date; + } + + return null; + } + + private static bool IsInRange(DateOnly value, DateOnly rangeStart, DateOnly rangeEnd) + { + return value >= rangeStart && value <= rangeEnd; + } + + private static IcsDateTimeValue Shift(IcsDateTimeValue value, int dayOffset) + { + return value with + { + Date = value.Date.AddDays(dayOffset), + LocalDateTime = value.LocalDateTime?.AddDays(dayOffset), + UtcDateTime = value.UtcDateTime?.AddDays(dayOffset), + }; + } + + private static IcsDateTimeValue ShiftByDuration(IcsDateTimeValue value, TimeSpan duration) + { + if (value.IsAllDay) + { + return value with { Date = value.Date.AddDays(Math.Max(1, (int)duration.TotalDays)) }; + } + + return value with + { + Date = value.LocalDateTime.HasValue + ? DateOnly.FromDateTime(value.LocalDateTime.Value.Add(duration)) + : value.Date, + LocalDateTime = value.LocalDateTime?.Add(duration), + UtcDateTime = value.UtcDateTime?.Add(duration), + }; + } +} diff --git a/backend/src/Socialize.Api/Modules/Comments/Data/Comment.cs b/backend/src/Socialize.Api/Modules/Comments/Data/Comment.cs index 14d3e7f..2ab05a8 100644 --- a/backend/src/Socialize.Api/Modules/Comments/Data/Comment.cs +++ b/backend/src/Socialize.Api/Modules/Comments/Data/Comment.cs @@ -10,7 +10,5 @@ public class Comment public required string AuthorDisplayName { get; set; } public required string AuthorEmail { get; set; } public required string Body { get; set; } - public bool IsResolved { get; set; } public DateTimeOffset CreatedAt { get; init; } - public DateTimeOffset? ResolvedAt { get; set; } } diff --git a/backend/src/Socialize.Api/Modules/Comments/Handlers/CreateComment.cs b/backend/src/Socialize.Api/Modules/Comments/Handlers/CreateComment.cs index fd346c4..330d97a 100644 --- a/backend/src/Socialize.Api/Modules/Comments/Handlers/CreateComment.cs +++ b/backend/src/Socialize.Api/Modules/Comments/Handlers/CreateComment.cs @@ -2,9 +2,11 @@ using FastEndpoints; using Microsoft.EntityFrameworkCore; using Socialize.Api.Data; using Socialize.Api.Infrastructure.Security; +using Socialize.Api.Modules.ContentItems.Contracts; using Socialize.Api.Modules.ContentItems.Data; using Socialize.Api.Modules.Comments.Data; using Socialize.Api.Modules.Notifications.Contracts; +using System.Text.Json; namespace Socialize.Api.Modules.Comments.Handlers; @@ -28,6 +30,7 @@ public class CreateCommentRequestValidator public class CreateCommentHandler( AppDbContext dbContext, AccessScopeService accessScopeService, + IContentItemActivityWriter activityWriter, INotificationEventWriter notificationEventWriter) : Endpoint { @@ -93,6 +96,22 @@ public class CreateCommentHandler( .Select(candidate => candidate.PortraitUrl) .SingleOrDefaultAsync(ct); + await activityWriter.WriteAsync( + new ContentItemActivityWriteModel( + comment.WorkspaceId, + comment.ContentItemId, + "comment.created", + "Comment", + comment.Id, + $"{comment.AuthorDisplayName} commented on {contentItem.Title}.", + comment.AuthorUserId, + comment.AuthorEmail, + JsonSerializer.Serialize(new + { + parentCommentId = comment.ParentCommentId, + })), + ct); + await notificationEventWriter.WriteAsync( new NotificationEventWriteModel( comment.WorkspaceId, @@ -116,9 +135,7 @@ public class CreateCommentHandler( comment.AuthorEmail, authorPortraitUrl, comment.Body, - comment.IsResolved, - comment.CreatedAt, - comment.ResolvedAt); + comment.CreatedAt); await SendAsync(dto, StatusCodes.Status201Created, ct); } diff --git a/backend/src/Socialize.Api/Modules/Comments/Handlers/GetComments.cs b/backend/src/Socialize.Api/Modules/Comments/Handlers/GetComments.cs index 73b6236..8da12e1 100644 --- a/backend/src/Socialize.Api/Modules/Comments/Handlers/GetComments.cs +++ b/backend/src/Socialize.Api/Modules/Comments/Handlers/GetComments.cs @@ -19,9 +19,7 @@ public record CommentDto( string AuthorEmail, string? AuthorPortraitUrl, string Body, - bool IsResolved, - DateTimeOffset CreatedAt, - DateTimeOffset? ResolvedAt); + DateTimeOffset CreatedAt); public class GetCommentsHandler( AppDbContext dbContext, @@ -75,9 +73,7 @@ public class GetCommentsHandler( comment.AuthorEmail, authorPortraits.GetValueOrDefault(comment.AuthorUserId), comment.Body, - comment.IsResolved, - comment.CreatedAt, - comment.ResolvedAt)) + comment.CreatedAt)) .ToList(); await SendOkAsync(dtos, ct); diff --git a/backend/src/Socialize.Api/Modules/Comments/Handlers/ResolveComment.cs b/backend/src/Socialize.Api/Modules/Comments/Handlers/ResolveComment.cs deleted file mode 100644 index bf42c1b..0000000 --- a/backend/src/Socialize.Api/Modules/Comments/Handlers/ResolveComment.cs +++ /dev/null @@ -1,89 +0,0 @@ -using FastEndpoints; -using Microsoft.EntityFrameworkCore; -using Socialize.Api.Data; -using Socialize.Api.Infrastructure.Security; -using Socialize.Api.Modules.Comments.Data; -using Socialize.Api.Modules.ContentItems.Data; -using Socialize.Api.Modules.Notifications.Contracts; - -namespace Socialize.Api.Modules.Comments.Handlers; - -public class ResolveCommentHandler( - AppDbContext dbContext, - AccessScopeService accessScopeService, - INotificationEventWriter notificationEventWriter) - : EndpointWithoutRequest -{ - public override void Configure() - { - Post("/api/comments/{id}/resolve"); - Options(o => o.WithTags("Comments")); - } - - public override async Task HandleAsync(CancellationToken ct) - { - Guid id = Route("id"); - - Comment? comment = await dbContext.Comments.SingleOrDefaultAsync(candidate => candidate.Id == id, ct); - if (comment is null) - { - await SendNotFoundAsync(ct); - return; - } - - ContentItem? contentItem = await dbContext.ContentItems - .SingleOrDefaultAsync(candidate => candidate.Id == comment.ContentItemId, ct); - if (contentItem is null) - { - await SendNotFoundAsync(ct); - return; - } - - bool canResolve = await accessScopeService.CanManageWorkspaceAsync(User, comment.WorkspaceId, ct) - || await accessScopeService.CanContributeToCampaignAsync(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId, ct); - - if (!canResolve) - { - await SendForbiddenAsync(ct); - return; - } - - comment.IsResolved = true; - comment.ResolvedAt = comment.ResolvedAt ?? DateTimeOffset.UtcNow; - await dbContext.SaveChangesAsync(ct); - - string? authorPortraitUrl = await dbContext.Users - .Where(candidate => candidate.Id == comment.AuthorUserId) - .Select(candidate => candidate.PortraitUrl) - .SingleOrDefaultAsync(ct); - - await notificationEventWriter.WriteAsync( - new NotificationEventWriteModel( - comment.WorkspaceId, - comment.ContentItemId, - "comment.resolved", - "Comment", - comment.Id, - $"{User.GetAlias() ?? User.GetName()} resolved a comment.", - null, - null, - null), - ct); - - CommentDto dto = new( - comment.Id, - comment.WorkspaceId, - comment.ContentItemId, - comment.ParentCommentId, - comment.AuthorUserId, - comment.AuthorDisplayName, - comment.AuthorEmail, - authorPortraitUrl, - comment.Body, - comment.IsResolved, - comment.CreatedAt, - comment.ResolvedAt); - - await SendOkAsync(dto, ct); - } -} diff --git a/backend/src/Socialize.Api/Modules/ContentItems/Contracts/IContentItemActivityWriter.cs b/backend/src/Socialize.Api/Modules/ContentItems/Contracts/IContentItemActivityWriter.cs new file mode 100644 index 0000000..982ff2f --- /dev/null +++ b/backend/src/Socialize.Api/Modules/ContentItems/Contracts/IContentItemActivityWriter.cs @@ -0,0 +1,17 @@ +namespace Socialize.Api.Modules.ContentItems.Contracts; + +public record ContentItemActivityWriteModel( + Guid WorkspaceId, + Guid ContentItemId, + string EventType, + string EntityType, + Guid EntityId, + string Summary, + Guid? ActorUserId, + string? ActorEmail, + string? MetadataJson); + +public interface IContentItemActivityWriter +{ + Task WriteAsync(ContentItemActivityWriteModel model, CancellationToken cancellationToken = default); +} diff --git a/backend/src/Socialize.Api/Modules/ContentItems/Data/ContentItemActivityEntry.cs b/backend/src/Socialize.Api/Modules/ContentItems/Data/ContentItemActivityEntry.cs new file mode 100644 index 0000000..10e0431 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/ContentItems/Data/ContentItemActivityEntry.cs @@ -0,0 +1,16 @@ +namespace Socialize.Api.Modules.ContentItems.Data; + +public class ContentItemActivityEntry +{ + public Guid Id { get; set; } + public Guid WorkspaceId { get; set; } + public Guid ContentItemId { get; set; } + public required string EventType { get; set; } + public required string EntityType { get; set; } + public Guid EntityId { get; set; } + public required string Summary { get; set; } + public Guid? ActorUserId { get; set; } + public string? ActorEmail { get; set; } + public string? MetadataJson { get; set; } + public DateTimeOffset CreatedAt { get; set; } +} diff --git a/backend/src/Socialize.Api/Modules/ContentItems/Data/ContentItemModelConfiguration.cs b/backend/src/Socialize.Api/Modules/ContentItems/Data/ContentItemModelConfiguration.cs index 04fabc9..3363d10 100644 --- a/backend/src/Socialize.Api/Modules/ContentItems/Data/ContentItemModelConfiguration.cs +++ b/backend/src/Socialize.Api/Modules/ContentItems/Data/ContentItemModelConfiguration.cs @@ -41,6 +41,23 @@ public static class ContentItemModelConfiguration revision.HasIndex(x => new { x.ContentItemId, x.RevisionNumber }).IsUnique(); }); + modelBuilder.Entity(entry => + { + entry.ToTable("ContentItemActivityEntries"); + entry.HasKey(x => x.Id); + entry.Property(x => x.EventType).HasMaxLength(128).IsRequired(); + entry.Property(x => x.EntityType).HasMaxLength(128).IsRequired(); + entry.Property(x => x.Summary).HasMaxLength(1024).IsRequired(); + entry.Property(x => x.ActorEmail).HasMaxLength(256); + entry.Property(x => x.MetadataJson).HasColumnType("jsonb"); + entry.Property(x => x.CreatedAt) + .ValueGeneratedOnAdd() + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + entry.HasIndex(x => x.WorkspaceId); + entry.HasIndex(x => x.ContentItemId); + entry.HasIndex(x => new { x.ContentItemId, x.CreatedAt }); + }); + return modelBuilder; } } diff --git a/backend/src/Socialize.Api/Modules/ContentItems/DependencyInjection.cs b/backend/src/Socialize.Api/Modules/ContentItems/DependencyInjection.cs index 1ac67af..fb5af91 100644 --- a/backend/src/Socialize.Api/Modules/ContentItems/DependencyInjection.cs +++ b/backend/src/Socialize.Api/Modules/ContentItems/DependencyInjection.cs @@ -1,4 +1,5 @@ -using Socialize.Api.Modules.ContentItems.Data; +using Socialize.Api.Modules.ContentItems.Contracts; +using Socialize.Api.Modules.ContentItems.Services; namespace Socialize.Api.Modules.ContentItems; @@ -7,6 +8,8 @@ public static class DependencyInjection public static WebApplicationBuilder AddContentItemsModule( this WebApplicationBuilder builder) { + builder.Services.AddScoped(); + return builder; } } diff --git a/backend/src/Socialize.Api/Modules/ContentItems/Handlers/CreateContentItem.cs b/backend/src/Socialize.Api/Modules/ContentItems/Handlers/CreateContentItem.cs index 23dbc78..53a05b8 100644 --- a/backend/src/Socialize.Api/Modules/ContentItems/Handlers/CreateContentItem.cs +++ b/backend/src/Socialize.Api/Modules/ContentItems/Handlers/CreateContentItem.cs @@ -2,9 +2,11 @@ using FastEndpoints; using Microsoft.EntityFrameworkCore; using Socialize.Api.Data; using Socialize.Api.Infrastructure.Security; +using Socialize.Api.Modules.ContentItems.Contracts; using Socialize.Api.Modules.Notifications.Contracts; using Socialize.Api.Modules.ContentItems.Data; using Socialize.Api.Modules.Workspaces.Data; +using System.Text.Json; namespace Socialize.Api.Modules.ContentItems.Handlers; @@ -36,6 +38,7 @@ public class CreateContentItemRequestValidator public class CreateContentItemHandler( AppDbContext dbContext, AccessScopeService accessScopeService, + IContentItemActivityWriter activityWriter, INotificationEventWriter notificationEventWriter) : Endpoint { @@ -121,6 +124,26 @@ public class CreateContentItemHandler( }); await dbContext.SaveChangesAsync(ct); + await activityWriter.WriteAsync( + new ContentItemActivityWriteModel( + item.WorkspaceId, + item.Id, + "content-item.created", + "ContentItem", + item.Id, + $"Content item {item.Title} was created.", + User.GetUserId(), + User.GetEmail(), + JsonSerializer.Serialize(new + { + status = item.Status, + revisionLabel = item.CurrentRevisionLabel, + dueDate = item.DueDate, + publicationTargets = item.PublicationTargets, + hashtags = item.Hashtags, + })), + ct); + await notificationEventWriter.WriteAsync( new NotificationEventWriteModel( item.WorkspaceId, diff --git a/backend/src/Socialize.Api/Modules/ContentItems/Handlers/CreateContentItemRevision.cs b/backend/src/Socialize.Api/Modules/ContentItems/Handlers/CreateContentItemRevision.cs index a1713c9..a4c0582 100644 --- a/backend/src/Socialize.Api/Modules/ContentItems/Handlers/CreateContentItemRevision.cs +++ b/backend/src/Socialize.Api/Modules/ContentItems/Handlers/CreateContentItemRevision.cs @@ -2,8 +2,10 @@ using FastEndpoints; using Microsoft.EntityFrameworkCore; using Socialize.Api.Data; using Socialize.Api.Infrastructure.Security; +using Socialize.Api.Modules.ContentItems.Contracts; using Socialize.Api.Modules.ContentItems.Data; using Socialize.Api.Modules.Notifications.Contracts; +using System.Text.Json; namespace Socialize.Api.Modules.ContentItems.Handlers; @@ -12,7 +14,8 @@ public record CreateContentItemRevisionRequest( string PublicationMessage, string PublicationTargets, string? Hashtags, - string? ChangeSummary); + string? ChangeSummary, + DateTimeOffset? DueDate); public class CreateContentItemRevisionRequestValidator : Validator @@ -30,6 +33,7 @@ public class CreateContentItemRevisionRequestValidator public class CreateContentItemRevisionHandler( AppDbContext dbContext, AccessScopeService accessScopeService, + IContentItemActivityWriter activityWriter, INotificationEventWriter notificationEventWriter) : Endpoint { @@ -58,11 +62,21 @@ public class CreateContentItemRevisionHandler( int revisionNumber = item.CurrentRevisionNumber + 1; string revisionLabel = $"v{revisionNumber}"; + string previousTitle = item.Title; + string previousPublicationMessage = item.PublicationMessage; + string previousPublicationTargets = item.PublicationTargets; + string? previousHashtags = item.Hashtags; + DateTimeOffset? previousDueDate = item.DueDate; + string newTitle = request.Title.Trim(); + string newPublicationMessage = request.PublicationMessage.Trim(); + string newPublicationTargets = request.PublicationTargets.Trim(); + string? newHashtags = string.IsNullOrWhiteSpace(request.Hashtags) ? null : request.Hashtags.Trim(); - item.Title = request.Title.Trim(); - item.PublicationMessage = request.PublicationMessage.Trim(); - item.PublicationTargets = request.PublicationTargets.Trim(); - item.Hashtags = string.IsNullOrWhiteSpace(request.Hashtags) ? null : request.Hashtags.Trim(); + item.Title = newTitle; + item.PublicationMessage = newPublicationMessage; + item.PublicationTargets = newPublicationTargets; + item.Hashtags = newHashtags; + item.DueDate = request.DueDate; item.CurrentRevisionNumber = revisionNumber; item.CurrentRevisionLabel = revisionLabel; @@ -84,6 +98,32 @@ public class CreateContentItemRevisionHandler( dbContext.ContentItemRevisions.Add(revision); await dbContext.SaveChangesAsync(ct); + List changedFields = []; + AddChangedField(changedFields, "title", previousTitle, item.Title); + AddChangedField(changedFields, "publicationMessage", previousPublicationMessage, item.PublicationMessage); + AddChangedField(changedFields, "publicationTargets", previousPublicationTargets, item.PublicationTargets); + AddChangedField(changedFields, "hashtags", previousHashtags, item.Hashtags); + AddChangedField(changedFields, "dueDate", previousDueDate, item.DueDate); + + await activityWriter.WriteAsync( + new ContentItemActivityWriteModel( + item.WorkspaceId, + item.Id, + "content-item.revision.created", + "ContentItemRevision", + revision.Id, + $"Revision {revisionLabel} was created for {item.Title}.", + User.GetUserId(), + User.GetEmail(), + JsonSerializer.Serialize(new + { + revisionLabel, + revisionNumber, + changeSummary = revision.ChangeSummary, + changedFields, + })), + ct); + await notificationEventWriter.WriteAsync( new NotificationEventWriteModel( item.WorkspaceId, @@ -112,4 +152,19 @@ public class CreateContentItemRevisionHandler( await SendAsync(dto, StatusCodes.Status201Created, ct); } + + private static void AddChangedField(List changedFields, string field, T oldValue, T newValue) + { + if (EqualityComparer.Default.Equals(oldValue, newValue)) + { + return; + } + + changedFields.Add(new + { + field, + oldValue, + newValue, + }); + } } diff --git a/backend/src/Socialize.Api/Modules/ContentItems/Handlers/GetContentItemActivity.cs b/backend/src/Socialize.Api/Modules/ContentItems/Handlers/GetContentItemActivity.cs new file mode 100644 index 0000000..54edaab --- /dev/null +++ b/backend/src/Socialize.Api/Modules/ContentItems/Handlers/GetContentItemActivity.cs @@ -0,0 +1,72 @@ +using FastEndpoints; +using Microsoft.EntityFrameworkCore; +using Socialize.Api.Data; +using Socialize.Api.Infrastructure.Security; +using Socialize.Api.Modules.ContentItems.Data; + +namespace Socialize.Api.Modules.ContentItems.Handlers; + +public record ContentItemActivityEntryDto( + Guid Id, + Guid WorkspaceId, + Guid ContentItemId, + string EventType, + string EntityType, + Guid EntityId, + string Summary, + Guid? ActorUserId, + string? ActorEmail, + string? MetadataJson, + DateTimeOffset CreatedAt); + +public class GetContentItemActivityHandler( + AppDbContext dbContext, + AccessScopeService accessScopeService) + : EndpointWithoutRequest> +{ + public override void Configure() + { + Get("/api/content-items/{id}/activity"); + Options(o => o.WithTags("Content Items")); + } + + public override async Task HandleAsync(CancellationToken ct) + { + Guid id = Route("id"); + + ContentItem? item = await dbContext.ContentItems + .SingleOrDefaultAsync(candidate => candidate.Id == id, ct); + + if (item is null) + { + await SendNotFoundAsync(ct); + return; + } + + if (!await accessScopeService.CanReviewContentAsync(User, item.WorkspaceId, item.ClientId, item.CampaignId, ct)) + { + await SendForbiddenAsync(ct); + return; + } + + List entries = await dbContext.ContentItemActivityEntries + .Where(entry => entry.ContentItemId == item.Id) + .OrderByDescending(entry => entry.CreatedAt) + .Take(200) + .Select(entry => new ContentItemActivityEntryDto( + entry.Id, + entry.WorkspaceId, + entry.ContentItemId, + entry.EventType, + entry.EntityType, + entry.EntityId, + entry.Summary, + entry.ActorUserId, + entry.ActorEmail, + entry.MetadataJson, + entry.CreatedAt)) + .ToListAsync(ct); + + await SendOkAsync(entries, ct); + } +} diff --git a/backend/src/Socialize.Api/Modules/ContentItems/Handlers/UpdateContentItemStatus.cs b/backend/src/Socialize.Api/Modules/ContentItems/Handlers/UpdateContentItemStatus.cs index 99d8602..c94b62c 100644 --- a/backend/src/Socialize.Api/Modules/ContentItems/Handlers/UpdateContentItemStatus.cs +++ b/backend/src/Socialize.Api/Modules/ContentItems/Handlers/UpdateContentItemStatus.cs @@ -3,9 +3,11 @@ using Microsoft.EntityFrameworkCore; using Socialize.Api.Data; using Socialize.Api.Infrastructure.Security; using Socialize.Api.Modules.Approvals.Services; +using Socialize.Api.Modules.ContentItems.Contracts; using Socialize.Api.Modules.ContentItems.Data; using Socialize.Api.Modules.Notifications.Contracts; using Socialize.Api.Modules.Workspaces.Data; +using System.Text.Json; namespace Socialize.Api.Modules.ContentItems.Handlers; @@ -24,6 +26,7 @@ public class UpdateContentItemStatusHandler( AppDbContext dbContext, AccessScopeService accessScopeService, ApprovalWorkflowRuntimeService approvalWorkflowRuntimeService, + IContentItemActivityWriter activityWriter, INotificationEventWriter notificationEventWriter) : Endpoint { @@ -122,12 +125,33 @@ public class UpdateContentItemStatusHandler( } } + string previousStatus = item.Status; if (item.Status != "In approval" || normalizedStatus != "In approval") { item.Status = normalizedStatus; } await dbContext.SaveChangesAsync(ct); + if (previousStatus != item.Status) + { + await activityWriter.WriteAsync( + new ContentItemActivityWriteModel( + item.WorkspaceId, + item.Id, + "content-item.status.updated", + "ContentItem", + item.Id, + $"Status changed from {previousStatus} to {item.Status} for {item.Title}.", + User.GetUserId(), + User.GetEmail(), + JsonSerializer.Serialize(new + { + oldValue = previousStatus, + newValue = item.Status, + })), + ct); + } + await notificationEventWriter.WriteAsync( new NotificationEventWriteModel( item.WorkspaceId, diff --git a/backend/src/Socialize.Api/Modules/ContentItems/Services/ContentItemActivityWriter.cs b/backend/src/Socialize.Api/Modules/ContentItems/Services/ContentItemActivityWriter.cs new file mode 100644 index 0000000..5bec2b3 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/ContentItems/Services/ContentItemActivityWriter.cs @@ -0,0 +1,31 @@ +using Socialize.Api.Data; +using Socialize.Api.Modules.ContentItems.Contracts; +using Socialize.Api.Modules.ContentItems.Data; + +namespace Socialize.Api.Modules.ContentItems.Services; + +public class ContentItemActivityWriter( + AppDbContext dbContext) + : IContentItemActivityWriter +{ + public async Task WriteAsync(ContentItemActivityWriteModel model, CancellationToken cancellationToken = default) + { + ContentItemActivityEntry entry = new() + { + Id = Guid.NewGuid(), + WorkspaceId = model.WorkspaceId, + ContentItemId = model.ContentItemId, + EventType = model.EventType, + EntityType = model.EntityType, + EntityId = model.EntityId, + Summary = model.Summary, + ActorUserId = model.ActorUserId, + ActorEmail = model.ActorEmail, + MetadataJson = model.MetadataJson, + CreatedAt = DateTimeOffset.UtcNow, + }; + + dbContext.ContentItemActivityEntries.Add(entry); + await dbContext.SaveChangesAsync(cancellationToken); + } +} diff --git a/backend/src/Socialize.Api/Modules/Organizations/Data/Organization.cs b/backend/src/Socialize.Api/Modules/Organizations/Data/Organization.cs index cab2b17..923eb41 100644 --- a/backend/src/Socialize.Api/Modules/Organizations/Data/Organization.cs +++ b/backend/src/Socialize.Api/Modules/Organizations/Data/Organization.cs @@ -4,6 +4,7 @@ public class Organization { public Guid Id { get; init; } public required string Name { get; set; } + public string? LogoUrl { get; set; } 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 e1a0f68..da9032c 100644 --- a/backend/src/Socialize.Api/Modules/Organizations/Data/OrganizationModelConfiguration.cs +++ b/backend/src/Socialize.Api/Modules/Organizations/Data/OrganizationModelConfiguration.cs @@ -11,6 +11,7 @@ public static class OrganizationModelConfiguration organization.ToTable("Organizations"); organization.HasKey(x => x.Id); organization.Property(x => x.Name).HasMaxLength(256).IsRequired(); + organization.Property(x => x.LogoUrl).HasMaxLength(2048); organization.Property(x => x.CreatedAt) .ValueGeneratedOnAdd() .HasDefaultValueSql("CURRENT_TIMESTAMP"); diff --git a/backend/src/Socialize.Api/Modules/Organizations/Handlers/AddOrganizationMember.cs b/backend/src/Socialize.Api/Modules/Organizations/Handlers/AddOrganizationMember.cs new file mode 100644 index 0000000..2d62d1e --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Organizations/Handlers/AddOrganizationMember.cs @@ -0,0 +1,129 @@ +using FastEndpoints; +using Microsoft.EntityFrameworkCore; +using Socialize.Api.Data; +using Socialize.Api.Modules.Identity.Data; +using Socialize.Api.Modules.Organizations.Data; +using Socialize.Api.Modules.Organizations.Services; + +namespace Socialize.Api.Modules.Organizations.Handlers; + +public record AddOrganizationMemberRequest( + string Email, + string Role); + +public class AddOrganizationMemberRequestValidator + : Validator +{ + private static readonly string[] AllowedRoles = + [ + OrganizationRoles.Admin, + OrganizationRoles.BillingManager, + OrganizationRoles.ConnectorManager, + OrganizationRoles.Member, + ]; + + public AddOrganizationMemberRequestValidator() + { + RuleFor(x => x.Email).NotEmpty().MaximumLength(256).EmailAddress(); + RuleFor(x => x.Role) + .NotEmpty() + .Must(role => AllowedRoles.Contains(role.Trim(), StringComparer.Ordinal)) + .WithMessage("A valid organization role should be specified."); + } +} + +public class AddOrganizationMemberHandler( + AppDbContext dbContext, + OrganizationAccessService organizationAccessService) + : Endpoint +{ + public override void Configure() + { + Post("/api/organizations/{organizationId:guid}/members"); + Options(o => o.WithTags("Organizations")); + } + + public override async Task HandleAsync(AddOrganizationMemberRequest request, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(request); + + Guid organizationId = Route("organizationId"); + + if (!await dbContext.Organizations.AnyAsync(organization => organization.Id == organizationId, ct)) + { + await SendNotFoundAsync(ct); + return; + } + + if (!await organizationAccessService.HasOrganizationPermissionAsync( + User, + organizationId, + OrganizationPermissions.ManageOrganizationMembers, + ct)) + { + await SendForbiddenAsync(ct); + return; + } + + string normalizedEmail = request.Email.Trim().ToUpperInvariant(); + User? user = await dbContext.Users + .SingleOrDefaultAsync(candidate => candidate.NormalizedEmail == normalizedEmail, ct); + if (user is null) + { + AddError(request => request.Email, "No user account exists for this email address."); + await SendErrorsAsync(StatusCodes.Status404NotFound, ct); + return; + } + + bool duplicateMembership = await dbContext.OrganizationMemberships.AnyAsync( + membership => membership.OrganizationId == organizationId && membership.UserId == user.Id, + ct); + if (duplicateMembership) + { + AddError(request => request.Email, "This user is already a member of the organization."); + await SendErrorsAsync(StatusCodes.Status409Conflict, ct); + return; + } + + string role = request.Role.Trim(); + OrganizationMembership membership = new() + { + Id = Guid.NewGuid(), + OrganizationId = organizationId, + UserId = user.Id, + Role = role, + CreatedAt = DateTimeOffset.UtcNow, + }; + + dbContext.OrganizationMemberships.Add(membership); + await dbContext.SaveChangesAsync(ct); + + await SendAsync( + new OrganizationMemberDto( + user.Id, + BuildDisplayName(user), + user.Email ?? string.Empty, + user.PortraitUrl, + membership.Role, + OrganizationPermissionRules.GetPermissionsForRole(membership.Role), + membership.CreatedAt), + StatusCodes.Status201Created, + ct); + } + + private static string BuildDisplayName(User user) + { + if (!string.IsNullOrWhiteSpace(user.Alias)) + { + return user.Alias; + } + + string fullName = $"{user.Firstname} {user.Lastname}".Trim(); + if (!string.IsNullOrWhiteSpace(fullName)) + { + return fullName; + } + + return user.Email ?? user.UserName ?? user.Id.ToString(); + } +} diff --git a/backend/src/Socialize.Api/Modules/Organizations/Handlers/ChangeOrganizationLogo.cs b/backend/src/Socialize.Api/Modules/Organizations/Handlers/ChangeOrganizationLogo.cs new file mode 100644 index 0000000..c9e4df6 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Organizations/Handlers/ChangeOrganizationLogo.cs @@ -0,0 +1,75 @@ +using FastEndpoints; +using Microsoft.EntityFrameworkCore; +using Socialize.Api.Data; +using Socialize.Api.Infrastructure.BlobStorage.Contracts; +using Socialize.Api.Modules.Organizations.Data; +using Socialize.Api.Modules.Organizations.Services; + +namespace Socialize.Api.Modules.Organizations.Handlers; + +public record ChangeOrganizationLogoRequest( + IFormFile File); + +public record ChangeOrganizationLogoResponse( + string BlobUrl); + +public sealed class ChangeOrganizationLogoRequestValidator : Validator +{ + public ChangeOrganizationLogoRequestValidator() + { + RuleFor(x => x.File) + .NotNull() + .NotEmpty(); + } +} + +public class ChangeOrganizationLogoHandler( + AppDbContext dbContext, + IBlobStorage blobStorage, + OrganizationAccessService organizationAccessService) + : Endpoint +{ + public override void Configure() + { + Post("/api/organizations/{organizationId:guid}/logo"); + Options(o => o.WithTags("Organizations")); + AllowFileUploads(); + } + + public override async Task HandleAsync(ChangeOrganizationLogoRequest request, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(request); + + 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.ManageOrganizationSettings, + ct)) + { + await SendForbiddenAsync(ct); + return; + } + + string blobUrl = await blobStorage.UploadFileAsync( + ContainerNames.Organizations, + $"{organization.Id}/{SubDirectoryNames.Profile}/{CommonFileNames.LogoPicture}", + request.File.OpenReadStream(), + request.File.ContentType, + ct); + + organization.LogoUrl = blobUrl; + await dbContext.SaveChangesAsync(ct); + + await SendOkAsync(new ChangeOrganizationLogoResponse(blobUrl), ct); + } +} diff --git a/backend/src/Socialize.Api/Modules/Organizations/Handlers/GetOrganization.cs b/backend/src/Socialize.Api/Modules/Organizations/Handlers/GetOrganization.cs index 84ca709..705bf68 100644 --- a/backend/src/Socialize.Api/Modules/Organizations/Handlers/GetOrganization.cs +++ b/backend/src/Socialize.Api/Modules/Organizations/Handlers/GetOrganization.cs @@ -44,13 +44,15 @@ public class GetOrganizationHandler( IReadOnlyCollection members = await GetMembersAsync(organizationId, ct); IReadOnlyCollection workspaces = await GetWorkspacesAsync(organizationId, ct); + OrganizationUsageDto usage = await GetUsageAsync(organization, ct); await SendOkAsync( OrganizationDto.FromOrganization( organization, currentUserPermissions, members, - workspaces), + workspaces, + usage), ct); } @@ -96,6 +98,57 @@ public class GetOrganizationHandler( .ToArray(); } + private async Task GetUsageAsync( + Organization organization, + CancellationToken ct) + { + Guid[] workspaceIds = await dbContext.Workspaces + .Where(workspace => workspace.OrganizationId == organization.Id) + .Select(workspace => workspace.Id) + .ToArrayAsync(ct); + + Guid[] memberUserIds = await dbContext.OrganizationMemberships + .Where(membership => membership.OrganizationId == organization.Id) + .Select(membership => membership.UserId) + .Distinct() + .ToArrayAsync(ct); + int userCount = memberUserIds + .Append(organization.OwnerUserId) + .Distinct() + .Count(); + + int activeContentItemCount = workspaceIds.Length == 0 + ? 0 + : await dbContext.ContentItems + .Where(contentItem => workspaceIds.Contains(contentItem.WorkspaceId) && + contentItem.Status != "Approved" && + contentItem.Status != "Scheduled") + .CountAsync(ct); + + OrganizationUsageLimits limits = GetUsageLimits(organization.Name); + + return new OrganizationUsageDto( + limits.PlanName, + [ + new OrganizationUsageItemDto("users", userCount, limits.UserLimit), + new OrganizationUsageItemDto("workspaces", workspaceIds.Length, limits.WorkspaceLimit), + new OrganizationUsageItemDto("activeContent", activeContentItemCount, limits.ActiveContentLimit), + ]); + } + + private static OrganizationUsageLimits GetUsageLimits(string organizationName) + { + return string.Equals(organizationName, "Northstar Agency", StringComparison.OrdinalIgnoreCase) + ? new OrganizationUsageLimits("Agency", 25, 15, 250) + : new OrganizationUsageLimits("Free", 2, 1, 3); + } + + private sealed record OrganizationUsageLimits( + string PlanName, + int UserLimit, + int WorkspaceLimit, + int ActiveContentLimit); + private static string BuildDisplayName(User user) { if (!string.IsNullOrWhiteSpace(user.Alias)) diff --git a/backend/src/Socialize.Api/Modules/Organizations/Handlers/OrganizationDtos.cs b/backend/src/Socialize.Api/Modules/Organizations/Handlers/OrganizationDtos.cs index 382ccb7..a2c28e4 100644 --- a/backend/src/Socialize.Api/Modules/Organizations/Handlers/OrganizationDtos.cs +++ b/backend/src/Socialize.Api/Modules/Organizations/Handlers/OrganizationDtos.cs @@ -15,25 +15,39 @@ public record OrganizationMemberDto( public record OrganizationDto( Guid Id, string Name, + string? LogoUrl, Guid OwnerUserId, IReadOnlyCollection CurrentUserPermissions, IReadOnlyCollection Members, IReadOnlyCollection Workspaces, + OrganizationUsageDto? Usage, DateTimeOffset CreatedAt) { public static OrganizationDto FromOrganization( Organization organization, IReadOnlyCollection currentUserPermissions, IReadOnlyCollection? members = null, - IReadOnlyCollection? workspaces = null) + IReadOnlyCollection? workspaces = null, + OrganizationUsageDto? usage = null) { return new OrganizationDto( organization.Id, organization.Name, + organization.LogoUrl, organization.OwnerUserId, currentUserPermissions, members ?? [], workspaces ?? [], + usage, organization.CreatedAt); } } + +public record OrganizationUsageDto( + string PlanName, + IReadOnlyCollection Items); + +public record OrganizationUsageItemDto( + string Key, + int Used, + int? Limit); diff --git a/backend/src/Socialize.Api/Modules/Organizations/Handlers/UpdateOrganization.cs b/backend/src/Socialize.Api/Modules/Organizations/Handlers/UpdateOrganization.cs new file mode 100644 index 0000000..1bccccf --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Organizations/Handlers/UpdateOrganization.cs @@ -0,0 +1,66 @@ +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; + +public record UpdateOrganizationRequest( + string Name); + +public class UpdateOrganizationRequestValidator + : Validator +{ + public UpdateOrganizationRequestValidator() + { + RuleFor(x => x.Name).NotEmpty().MaximumLength(256); + } +} + +public class UpdateOrganizationHandler( + AppDbContext dbContext, + OrganizationAccessService organizationAccessService) + : Endpoint +{ + public override void Configure() + { + Put("/api/organizations/{organizationId:guid}"); + Options(o => o.WithTags("Organizations")); + } + + public override async Task HandleAsync(UpdateOrganizationRequest request, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(request); + + 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.ManageOrganizationSettings, + ct)) + { + await SendForbiddenAsync(ct); + return; + } + + organization.Name = request.Name.Trim(); + await dbContext.SaveChangesAsync(ct); + + IReadOnlyCollection currentUserPermissions = await organizationAccessService.GetUserOrganizationPermissionsAsync( + User, + organizationId, + ct); + + await SendOkAsync(OrganizationDto.FromOrganization(organization, currentUserPermissions), ct); + } +} diff --git a/backend/src/Socialize.Api/Program.cs b/backend/src/Socialize.Api/Program.cs index f91d128..61939e7 100644 --- a/backend/src/Socialize.Api/Program.cs +++ b/backend/src/Socialize.Api/Program.cs @@ -18,6 +18,7 @@ using Socialize.Api.Modules.Feedback; using Socialize.Api.Modules.Identity; using Socialize.Api.Modules.Notifications; using Socialize.Api.Modules.Campaigns; +using Socialize.Api.Modules.CalendarIntegrations; using Socialize.Api.Modules.Organizations; using Socialize.Api.Modules.Workspaces; @@ -75,6 +76,7 @@ builder.AddCommentsModule(); builder.AddApprovalsModule(); builder.AddNotificationsModule(); builder.AddFeedbackModule(); +builder.AddCalendarIntegrationsModule(); var app = builder.Build(); diff --git a/backend/tests/Socialize.Tests/CalendarIntegrations/CalendarExportFeedTests.cs b/backend/tests/Socialize.Tests/CalendarIntegrations/CalendarExportFeedTests.cs new file mode 100644 index 0000000..9af6d26 --- /dev/null +++ b/backend/tests/Socialize.Tests/CalendarIntegrations/CalendarExportFeedTests.cs @@ -0,0 +1,63 @@ +using Socialize.Api.Modules.CalendarIntegrations.Services; + +namespace Socialize.Tests.CalendarIntegrations; + +public class CalendarExportFeedTests +{ + [Fact] + public void Token_regeneration_changes_private_feed_secret() + { + string firstToken = CalendarExportFeedTokenService.GenerateToken(); + string secondToken = CalendarExportFeedTokenService.GenerateToken(); + + Assert.NotEqual(firstToken, secondToken); + Assert.NotEqual( + CalendarExportFeedTokenService.HashToken(firstToken), + CalendarExportFeedTokenService.HashToken(secondToken)); + } + + [Fact] + public void HashToken_allows_token_authorization_boundary_checks_without_plaintext_comparison() + { + string token = CalendarExportFeedTokenService.GenerateToken(); + string storedHash = CalendarExportFeedTokenService.HashToken(token); + + Assert.Equal(storedHash, CalendarExportFeedTokenService.HashToken(token)); + Assert.NotEqual(storedHash, CalendarExportFeedTokenService.HashToken(CalendarExportFeedTokenService.GenerateToken())); + } + + [Fact] + public void Build_emits_valid_ics_for_user_work_without_sensitive_discussion_details() + { + CalendarExportFeedBuilder builder = new(); + string ics = builder.Build( + "Socialize my work", + [ + new CalendarExportFeedEvent( + "content-1@socialize", + "Launch reel", + new DateTimeOffset(2026, 5, 10, 9, 0, 0, TimeSpan.Zero), + new DateTimeOffset(2026, 5, 10, 9, 30, 0, TimeSpan.Zero), + IsAllDay: false, + "Status: Draft\nWorkspace: Brand A\nClient: Client\nCampaign: Spring launch", + "https://app.test/app/content/content-1"), + new CalendarExportFeedEvent( + "approval-1@socialize", + "Approval due: Launch reel", + new DateTimeOffset(2026, 5, 12, 0, 0, 0, TimeSpan.Zero), + new DateTimeOffset(2026, 5, 13, 0, 0, 0, TimeSpan.Zero), + IsAllDay: true, + "Stage: Client review\nState: Pending\nWorkspace: Brand A", + "https://app.test/app/content/content-1"), + ]); + + Assert.StartsWith("BEGIN:VCALENDAR", ics); + Assert.Contains("SUMMARY:Launch reel", ics); + Assert.Contains("SUMMARY:Approval due: Launch reel", ics); + Assert.Contains("DTSTART:20260510T090000Z", ics); + Assert.Contains("DTSTART;VALUE=DATE:20260512", ics); + Assert.DoesNotContain("approval-token", ics); + Assert.DoesNotContain("Mother's Day", ics); + Assert.EndsWith("END:VCALENDAR" + Environment.NewLine, ics); + } +} diff --git a/backend/tests/Socialize.Tests/CalendarIntegrations/CalendarImportSyncServiceTests.cs b/backend/tests/Socialize.Tests/CalendarIntegrations/CalendarImportSyncServiceTests.cs new file mode 100644 index 0000000..30804d9 --- /dev/null +++ b/backend/tests/Socialize.Tests/CalendarIntegrations/CalendarImportSyncServiceTests.cs @@ -0,0 +1,16 @@ +using Socialize.Api.Modules.CalendarIntegrations.Services; + +namespace Socialize.Tests.CalendarIntegrations; + +public class CalendarImportSyncServiceTests +{ + [Fact] + public void NormalizeSyncError_truncates_errors_to_stored_length() + { + string message = new('x', 3000); + + string normalized = CalendarImportSyncService.NormalizeSyncError(message); + + Assert.Equal(2048, normalized.Length); + } +} diff --git a/backend/tests/Socialize.Tests/CalendarIntegrations/CalendarSourceRulesTests.cs b/backend/tests/Socialize.Tests/CalendarIntegrations/CalendarSourceRulesTests.cs new file mode 100644 index 0000000..1129040 --- /dev/null +++ b/backend/tests/Socialize.Tests/CalendarIntegrations/CalendarSourceRulesTests.cs @@ -0,0 +1,87 @@ +using Socialize.Api.Modules.CalendarIntegrations.Data; +using Socialize.Api.Modules.CalendarIntegrations.Handlers; +using Socialize.Api.Modules.CalendarIntegrations.Services; + +namespace Socialize.Tests.CalendarIntegrations; + +public class CalendarSourceRulesTests +{ + [Theory] + [InlineData(CalendarSourceScopes.Organization, true, false, true)] + [InlineData(CalendarSourceScopes.Organization, false, true, false)] + [InlineData(CalendarSourceScopes.Workspace, false, true, true)] + [InlineData(CalendarSourceScopes.Workspace, true, false, false)] + public void CanManageScope_uses_scope_specific_shared_calendar_permissions( + string scope, + bool canManageOrganizationCalendars, + bool canManageWorkspaceCalendars, + bool expected) + { + Guid currentUserId = Guid.NewGuid(); + + bool actual = CalendarSourceRules.CanManageScope( + scope, + canManageOrganizationCalendars, + canManageWorkspaceCalendars, + currentUserId, + sourceUserId: null); + + Assert.Equal(expected, actual); + } + + [Fact] + public void CanManageScope_allows_only_owner_for_user_sources() + { + Guid currentUserId = Guid.NewGuid(); + + Assert.True(CalendarSourceRules.CanManageScope( + CalendarSourceScopes.User, + canManageOrganizationCalendars: false, + canManageWorkspaceCalendars: false, + currentUserId, + currentUserId)); + + Assert.False(CalendarSourceRules.CanManageScope( + CalendarSourceScopes.User, + canManageOrganizationCalendars: true, + canManageWorkspaceCalendars: true, + currentUserId, + Guid.NewGuid())); + } + + [Fact] + public void Workspace_context_marks_inherited_organization_sources_read_only() + { + Guid organizationId = Guid.NewGuid(); + CalendarSource organizationSource = new() + { + Id = Guid.NewGuid(), + Scope = CalendarSourceScopes.Organization, + OrganizationId = organizationId, + DisplayTitle = "Public holidays", + Color = "#2F80ED", + Category = "public-holiday", + InheritanceMode = CalendarSourceInheritanceModes.Required, + }; + + CalendarSource workspaceSource = new() + { + Id = Guid.NewGuid(), + Scope = CalendarSourceScopes.Workspace, + WorkspaceId = Guid.NewGuid(), + DisplayTitle = "Campaign moments", + Color = "#27AE60", + Category = "marketing-moment", + }; + + CalendarSourceDto inheritedDto = CalendarSourceDto.FromSource( + organizationSource, + CalendarSourceRules.IsInheritedOrganizationSource(organizationSource, organizationId)); + CalendarSourceDto workspaceDto = CalendarSourceDto.FromSource( + workspaceSource, + CalendarSourceRules.IsInheritedOrganizationSource(workspaceSource, organizationId)); + + Assert.True(inheritedDto.IsReadOnly); + Assert.False(workspaceDto.IsReadOnly); + } +} diff --git a/backend/tests/Socialize.Tests/CalendarIntegrations/IcsCalendarParserTests.cs b/backend/tests/Socialize.Tests/CalendarIntegrations/IcsCalendarParserTests.cs new file mode 100644 index 0000000..609170d --- /dev/null +++ b/backend/tests/Socialize.Tests/CalendarIntegrations/IcsCalendarParserTests.cs @@ -0,0 +1,132 @@ +using Socialize.Api.Modules.CalendarIntegrations.Services; + +namespace Socialize.Tests.CalendarIntegrations; + +public class IcsCalendarParserTests +{ + private readonly IcsCalendarParser _parser = new(); + + [Fact] + public void Parse_preserves_all_day_calendar_dates() + { + string ics = """ + BEGIN:VCALENDAR + BEGIN:VEVENT + UID:christmas-eve + SUMMARY:Christmas Eve + DTSTART;VALUE=DATE:20261224 + DTEND;VALUE=DATE:20261225 + END:VEVENT + END:VCALENDAR + """; + + ParsedCalendarEvent calendarEvent = Assert.Single(_parser.Parse( + ics, + new DateOnly(2026, 12, 1), + new DateOnly(2026, 12, 31))); + + Assert.True(calendarEvent.IsAllDay); + Assert.Equal(new DateOnly(2026, 12, 24), calendarEvent.StartDate); + Assert.Equal(new DateOnly(2026, 12, 25), calendarEvent.EndDate); + Assert.Null(calendarEvent.StartUtc); + } + + [Fact] + public void Parse_keeps_floating_timed_events_as_local_values_without_utc_conversion() + { + string ics = """ + BEGIN:VCALENDAR + BEGIN:VEVENT + UID:floating + SUMMARY:Local planning + DTSTART:20260510T090000 + DTEND:20260510T100000 + END:VEVENT + END:VCALENDAR + """; + + ParsedCalendarEvent calendarEvent = Assert.Single(_parser.Parse( + ics, + new DateOnly(2026, 5, 1), + new DateOnly(2026, 5, 31))); + + Assert.False(calendarEvent.IsAllDay); + Assert.True(calendarEvent.IsFloatingTime); + Assert.Equal(new DateTime(2026, 5, 10, 9, 0, 0), calendarEvent.StartLocalDateTime); + Assert.Null(calendarEvent.StartUtc); + } + + [Fact] + public void Parse_converts_timezone_bearing_timed_events_when_timezone_is_known() + { + string ics = """ + BEGIN:VCALENDAR + BEGIN:VEVENT + UID:timed + SUMMARY:Launch + DTSTART;TZID=America/Toronto:20260510T090000 + DTEND;TZID=America/Toronto:20260510T100000 + END:VEVENT + END:VCALENDAR + """; + + ParsedCalendarEvent calendarEvent = Assert.Single(_parser.Parse( + ics, + new DateOnly(2026, 5, 1), + new DateOnly(2026, 5, 31))); + + Assert.False(calendarEvent.IsFloatingTime); + Assert.Equal("America/Toronto", calendarEvent.TimeZoneId); + Assert.Equal(TimeSpan.Zero, calendarEvent.StartUtc?.Offset); + Assert.Equal(13, calendarEvent.StartUtc?.Hour); + } + + [Fact] + public void Parse_expands_yearly_recurrence_inside_requested_range() + { + string ics = """ + BEGIN:VCALENDAR + BEGIN:VEVENT + UID:mothers-day + SUMMARY:Mother's Day + DTSTART;VALUE=DATE:20240512 + DTEND;VALUE=DATE:20240513 + RRULE:FREQ=YEARLY;COUNT=5 + END:VEVENT + END:VCALENDAR + """; + + IReadOnlyCollection events = _parser.Parse( + ics, + new DateOnly(2026, 1, 1), + new DateOnly(2027, 12, 31)); + + Assert.Collection( + events, + first => Assert.Equal(new DateOnly(2026, 5, 12), first.StartDate), + second => Assert.Equal(new DateOnly(2027, 5, 12), second.StartDate)); + Assert.All(events, calendarEvent => Assert.Equal("mothers-day", calendarEvent.RecurrenceId)); + } + + [Fact] + public void Parse_unfolds_folded_text_lines() + { + string ics = """ + BEGIN:VCALENDAR + BEGIN:VEVENT + UID:folded + SUMMARY:Long + title + DTSTART;VALUE=DATE:20260510 + END:VEVENT + END:VCALENDAR + """; + + ParsedCalendarEvent calendarEvent = Assert.Single(_parser.Parse( + ics, + new DateOnly(2026, 5, 1), + new DateOnly(2026, 5, 31))); + + Assert.Equal("Longtitle", calendarEvent.Title); + } +} diff --git a/docs/FEATURES/calendar-integrations.md b/docs/FEATURES/calendar-integrations.md index 135866a..1114074 100644 --- a/docs/FEATURES/calendar-integrations.md +++ b/docs/FEATURES/calendar-integrations.md @@ -154,15 +154,9 @@ Each calendar source has a configurable color. ## Calendar Source Control -The Content calendar includes a compact calendar source control. +The Content calendar includes a compact calendar source dropdown placed next to the calendar view selector. -The control lists currently displayed calendar sources, grouped or labeled by: - -- Organization -- Workspace -- My calendars - -Each source has a visibility toggle. Inherited read-only sources still appear in this list. The last entry is `Add calendar`. +The dropdown lists currently displayed calendar sources with each source's visibility toggle. Inherited read-only sources still appear in this list. The last entry is `Add calendar`. The `Add calendar` flow lets users search the curated catalog or add a custom `.ics` URL, subject to their permissions and selected scope. diff --git a/docs/TASKS/calendar-integrations/003-content-calendar-ui-integration.md b/docs/TASKS/calendar-integrations/003-content-calendar-ui-integration.md index cab0f0f..2175665 100644 --- a/docs/TASKS/calendar-integrations/003-content-calendar-ui-integration.md +++ b/docs/TASKS/calendar-integrations/003-content-calendar-ui-integration.md @@ -13,7 +13,7 @@ Show imported calendar events and calendar source controls in the Content calend - Add calendar source data loading to the Content feature. - Render imported events as read-only calendar context entries in Month, Week, and Upcoming views. - Style imported events distinctly from Socialize content items and apply source colors. -- Add a calendar source control grouped by Organization, Workspace, and My calendars. +- Add a compact calendar source dropdown next to the Content calendar view selector. - Add visibility toggles for displayed sources. - Add `Add calendar` as the last source-control entry. - Add an add-calendar flow that supports curated catalog search and custom `.ics` URL subscriptions according to user permissions. diff --git a/docs/TASKS/content/004-content-production-collaboration-panel.md b/docs/TASKS/content/004-content-production-collaboration-panel.md new file mode 100644 index 0000000..a834fa4 --- /dev/null +++ b/docs/TASKS/content/004-content-production-collaboration-panel.md @@ -0,0 +1,46 @@ +# Task: Add content production collaboration panel + +## Feature + +`docs/FEATURES/production-workflow.md` + +## Goal + +Make the content detail page expose the existing production collaboration data that is already loaded by the frontend store: comments, content revisions, linked assets, asset revisions, and workflow activity. + +## Scope + +- Add a compact production collaboration panel to `ContentItemDetailView`. +- Keep comment creation and resolution available. +- Show content revision history with change summaries. +- Show linked assets and their revisions. +- Add UI for linking a Google Drive asset and adding a new asset revision. +- Show content-scoped notification activity as a read-only production activity feed. + +## Likely Files + +- `frontend/src/features/content/views/ContentItemDetailView.vue` +- `frontend/src/features/content/stores/contentItemDetailStore.js` + +## Out Of Scope + +- Backend schema changes. +- Native file uploads. +- Mention parsing. +- Approval comment visibility rules. +- Reworking content variant persistence. + +## Validation + +```bash +cd frontend +npm run build +``` + +## Acceptance Criteria + +- [x] Comments, revisions, assets, and activity are visible from the content detail page. +- [x] Users can post comments from the production panel. +- [x] Users can link a Google Drive asset to a content item. +- [x] Users can add a new revision URL/reference to an existing asset. +- [x] Existing create/edit content and approval controls remain available. diff --git a/docs/TASKS/content/005-scope-content-editor-channels.md b/docs/TASKS/content/005-scope-content-editor-channels.md new file mode 100644 index 0000000..5f07d83 --- /dev/null +++ b/docs/TASKS/content/005-scope-content-editor-channels.md @@ -0,0 +1,32 @@ +# Task: Scope content editor channels to item workspace + +## Feature + +`docs/FEATURES/channels.md` + +## Goal + +Prevent content item channel placements from mixing channels across workspaces when the app is viewed in an all-workspaces scope. + +## Context + +Seeded content such as `Bakery loyalty carousel` should only use channels from its own workspace. The seed source assigns it to `Atlas Bakery Instagram`, but stale editor drafts or all-workspaces channel options can show unrelated Luma channels in the content detail editor. + +## Scope + +- Limit content detail channel options to the content item's workspace. +- Deduplicate publication target parsing and summary serialization. +- Normalize restored editor drafts so duplicate or other-workspace known channels are not kept. + +## Validation + +```bash +cd frontend +npm run build +``` + +## Acceptance Criteria + +- [x] Existing content items only offer channels from their own workspace. +- [x] Duplicate publication target strings render as one placement. +- [x] Stale restored drafts do not keep known channels from other workspaces. diff --git a/docs/TASKS/content/006-content-activity-endpoint.md b/docs/TASKS/content/006-content-activity-endpoint.md new file mode 100644 index 0000000..b1f14e7 --- /dev/null +++ b/docs/TASKS/content/006-content-activity-endpoint.md @@ -0,0 +1,40 @@ +# Task: Add content activity endpoint + +## Feature + +`docs/FEATURES/production-workflow.md` + +## Goal + +Add a content-owned activity history endpoint that is separate from user-facing notifications. + +## Scope + +- Add persisted content activity entries. +- Add `GET /api/content-items/{id}/activity`. +- Log content creation, revisions, status changes, comments, linked assets, and asset revisions. +- Include field-level metadata for content revision changes such as title, message, channels, hashtags, and publish date. +- Persist publish date changes sent from the content editor revision flow. +- Use the activity endpoint from the content detail production activity tab. + +## Out Of Scope + +- Full diff rendering UI. +- Deleting tags or assets. +- Notification recipient behavior changes. + +## Validation + +```bash +dotnet build backend/Socialize.slnx +cd frontend +npm run build +``` + +## Acceptance Criteria + +- [x] `GET /api/content-items/{id}/activity` returns content history for users who can review the content item. +- [x] Activity entries are not filtered by notification recipients. +- [x] Content revision activity records changed fields. +- [x] Publish date changes are saved and included in content activity. +- [x] The content detail activity tab reads from content activity instead of notifications. diff --git a/docs/TASKS/organizations/006-organization-settings-editing.md b/docs/TASKS/organizations/006-organization-settings-editing.md new file mode 100644 index 0000000..1d36dd1 --- /dev/null +++ b/docs/TASKS/organizations/006-organization-settings-editing.md @@ -0,0 +1,46 @@ +# Task: Organization settings editing + +## Feature + +`docs/FEATURES/organizations.md` + +## Goal + +Allow permitted organization users to edit the organization name and add organization members from the settings page. + +## Scope + +- Add an API endpoint to update organization profile settings. +- Add an API endpoint to add an existing user as an organization member by email and role. +- Add organization logo storage and an API endpoint to upload a cropped organization logo. +- Replace the organization Workspaces settings tab with a Usage tab. +- Wire the organization settings profile section to save the organization name. +- Wire the organization settings profile section to change the organization logo. +- Show current organization usage against preview plan limits. +- Wire the organization settings members section to add members. +- Add English and French UI strings. + +## Constraints + +- Do not implement email delivery or pending organization invitation tokens in this task. +- Do not change workspace invite behavior. +- Keep organization code under `backend/src/Socialize.Api/Modules/Organizations`. +- Keep frontend organization code under `frontend/src/features/organizations`. + +## Done When + +- [x] Users with `ManageOrganizationSettings` can update the organization name. +- [x] Users with `ManageOrganizationSettings` can update the organization logo. +- [x] Organization settings show Usage instead of Workspaces. +- [x] Users with `ManageOrganizationMembers` can add an existing user as an organization member. +- [x] Duplicate organization memberships are rejected. +- [x] Frontend build passes. +- [x] Backend build passes. + +## Validation Commands + +```bash +dotnet build backend/Socialize.slnx +cd frontend +npm run build +``` diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index 7bd9234..c0d2c40 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -100,6 +100,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/organizations/{organizationId}/members": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["SocializeApiModulesOrganizationsHandlersAddOrganizationMemberHandler"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/organizations/{organizationId}": { parameters: { query?: never; @@ -108,7 +124,7 @@ export interface paths { cookie?: never; }; get: operations["SocializeApiModulesOrganizationsHandlersGetOrganizationHandler"]; - put?: never; + put: operations["SocializeApiModulesOrganizationsHandlersUpdateOrganizationHandler"]; post?: never; delete?: never; options?: never; @@ -820,6 +836,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/calendar-integrations/sources": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["SocializeApiModulesCalendarIntegrationsHandlersListCalendarSourcesHandler"]; + put?: never; + post: operations["SocializeApiModulesCalendarIntegrationsHandlersCreateCalendarSourceHandler"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/calendar-integrations/sources/{sourceId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["SocializeApiModulesCalendarIntegrationsHandlersUpdateCalendarSourceHandler"]; + post?: never; + delete: operations["SocializeApiModulesCalendarIntegrationsHandlersDeleteCalendarSourceHandler"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/assets/{id}/revisions": { parameters: { query?: never; @@ -1003,6 +1051,22 @@ export interface components { /** Format: int32 */ requiredApproverCount?: number; }; + SocializeApiModulesOrganizationsHandlersOrganizationMemberDto: { + /** Format: guid */ + userId?: string; + displayName?: string; + email?: string; + portraitUrl?: string | null; + role?: string; + permissions?: string[]; + /** Format: date-time */ + createdAt?: string; + }; + SocializeApiModulesOrganizationsHandlersAddOrganizationMemberRequest: { + /** Format: email */ + email: string; + role: string; + }; SocializeApiModulesOrganizationsHandlersOrganizationDto: { /** Format: guid */ id?: string; @@ -1015,16 +1079,8 @@ export interface components { /** Format: date-time */ createdAt?: string; }; - SocializeApiModulesOrganizationsHandlersOrganizationMemberDto: { - /** Format: guid */ - userId?: string; - displayName?: string; - email?: string; - portraitUrl?: string | null; - role?: string; - permissions?: string[]; - /** Format: date-time */ - createdAt?: string; + SocializeApiModulesOrganizationsHandlersUpdateOrganizationRequest: { + name: string; }; SocializeApiModulesNotificationsHandlersNotificationEventDto: { /** Format: guid */ @@ -1478,6 +1534,49 @@ export interface components { notes?: string | null; }; SocializeApiModulesCampaignsHandlersGetCampaignsRequest: Record; + SocializeApiModulesCalendarIntegrationsHandlersCalendarSourceDto: { + /** Format: guid */ + id?: string; + scope?: string; + /** Format: guid */ + organizationId?: string | null; + /** Format: guid */ + workspaceId?: string | null; + /** Format: guid */ + userId?: string | null; + sourceUrl?: string | null; + catalogSourceReference?: string | null; + displayTitle?: string; + color?: string; + category?: string; + isEnabled?: boolean; + inheritanceMode?: string | null; + isReadOnly?: boolean; + /** Format: date-time */ + lastSuccessfulSyncAt?: string | null; + /** Format: date-time */ + lastAttemptedSyncAt?: string | null; + lastSyncError?: string | null; + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + }; + SocializeApiModulesCalendarIntegrationsHandlersUpsertCalendarSourceRequest: { + scope: string; + /** Format: guid */ + organizationId?: string | null; + /** Format: guid */ + workspaceId?: string | null; + sourceUrl?: string | null; + catalogSourceReference?: string | null; + displayTitle: string; + color: string; + category: string; + isEnabled?: boolean; + inheritanceMode?: string | null; + }; + SocializeApiModulesCalendarIntegrationsHandlersListCalendarSourcesRequest: Record; SocializeApiModulesAssetsHandlersAssetRevisionDto: { /** Format: guid */ id?: string; @@ -1860,6 +1959,48 @@ export interface operations { }; }; }; + SocializeApiModulesOrganizationsHandlersAddOrganizationMemberHandler: { + parameters: { + query?: never; + header?: never; + path: { + organizationId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SocializeApiModulesOrganizationsHandlersAddOrganizationMemberRequest"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SocializeApiModulesOrganizationsHandlersOrganizationMemberDto"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["FastEndpointsErrorResponse"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; SocializeApiModulesOrganizationsHandlersGetOrganizationHandler: { parameters: { query?: never; @@ -1889,6 +2030,48 @@ export interface operations { }; }; }; + SocializeApiModulesOrganizationsHandlersUpdateOrganizationHandler: { + parameters: { + query?: never; + header?: never; + path: { + organizationId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SocializeApiModulesOrganizationsHandlersUpdateOrganizationRequest"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SocializeApiModulesOrganizationsHandlersOrganizationDto"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["FastEndpointsErrorResponse"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; SocializeApiModulesOrganizationsHandlersGetOrganizationsHandler: { parameters: { query?: never; @@ -3602,6 +3785,144 @@ export interface operations { }; }; }; + SocializeApiModulesCalendarIntegrationsHandlersListCalendarSourcesHandler: { + 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"]["SocializeApiModulesCalendarIntegrationsHandlersCalendarSourceDto"][]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + SocializeApiModulesCalendarIntegrationsHandlersCreateCalendarSourceHandler: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SocializeApiModulesCalendarIntegrationsHandlersUpsertCalendarSourceRequest"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SocializeApiModulesCalendarIntegrationsHandlersCalendarSourceDto"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["FastEndpointsErrorResponse"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + SocializeApiModulesCalendarIntegrationsHandlersUpdateCalendarSourceHandler: { + parameters: { + query?: never; + header?: never; + path: { + sourceId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SocializeApiModulesCalendarIntegrationsHandlersUpsertCalendarSourceRequest"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SocializeApiModulesCalendarIntegrationsHandlersCalendarSourceDto"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["FastEndpointsErrorResponse"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + SocializeApiModulesCalendarIntegrationsHandlersDeleteCalendarSourceHandler: { + parameters: { + query?: never; + header?: never; + path: { + sourceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; SocializeApiModulesAssetsHandlersCreateAssetRevisionHandler: { parameters: { query?: never; diff --git a/frontend/src/features/content/stores/calendarIntegrationsStore.js b/frontend/src/features/content/stores/calendarIntegrationsStore.js new file mode 100644 index 0000000..be8b7b7 --- /dev/null +++ b/frontend/src/features/content/stores/calendarIntegrationsStore.js @@ -0,0 +1,182 @@ +import { computed, ref } from 'vue'; +import { defineStore } from 'pinia'; +import { useAuthStore } from '@/features/auth/stores/authStore.js'; +import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js'; +import { useClient } from '@/plugins/api.js'; + +export const useCalendarIntegrationsStore = defineStore('calendar-integrations', () => { + const authStore = useAuthStore(); + const workspaceStore = useWorkspaceStore(); + const client = useClient(); + + const sources = ref([]); + const events = ref([]); + const catalogEntries = ref([]); + const hiddenSourceIds = ref(new Set()); + const isLoadingSources = ref(false); + const isLoadingEvents = ref(false); + const isLoadingCatalog = ref(false); + const isCreatingSource = ref(false); + const error = ref(null); + + const visibleSourceIds = computed(() => + new Set(sources.value + .filter(source => source.isEnabled && !hiddenSourceIds.value.has(source.id)) + .map(source => source.id)) + ); + + const visibleEvents = computed(() => + events.value.filter(event => visibleSourceIds.value.has(event.calendarSourceId)) + ); + + function sourceById(sourceId) { + return sources.value.find(source => source.id === sourceId) ?? null; + } + + async function fetchSources(workspaceId = workspaceStore.activeWorkspaceId) { + if (!authStore.isAuthenticated) { + sources.value = []; + error.value = null; + return; + } + + isLoadingSources.value = true; + error.value = null; + + try { + const response = await client.get('/api/calendar-integrations/sources', { + params: { + workspaceId: workspaceId ?? undefined, + }, + }); + sources.value = response.data ?? []; + } catch (fetchError) { + console.error('Failed to fetch calendar sources:', fetchError); + sources.value = []; + error.value = 'Failed to load calendar sources.'; + } finally { + isLoadingSources.value = false; + } + } + + async function fetchEvents({ workspaceId = workspaceStore.activeWorkspaceId, startDate, endDate } = {}) { + if (!authStore.isAuthenticated) { + events.value = []; + error.value = null; + return; + } + + isLoadingEvents.value = true; + + try { + const response = await client.get('/api/calendar-integrations/events', { + params: { + workspaceId: workspaceId ?? undefined, + startDate, + endDate, + }, + }); + events.value = response.data ?? []; + } catch (fetchError) { + console.error('Failed to fetch calendar events:', fetchError); + events.value = []; + error.value = 'Failed to load calendar events.'; + } finally { + isLoadingEvents.value = false; + } + } + + async function searchCatalog(filters = {}) { + if (!authStore.isAuthenticated) { + catalogEntries.value = []; + return; + } + + isLoadingCatalog.value = true; + + try { + const response = await client.get('/api/calendar-integrations/catalog', { + params: filters, + }); + catalogEntries.value = response.data ?? []; + } catch (fetchError) { + console.error('Failed to search calendar catalog:', fetchError); + catalogEntries.value = []; + error.value = 'Failed to load calendar catalog.'; + } finally { + isLoadingCatalog.value = false; + } + } + + async function createSource(payload) { + isCreatingSource.value = true; + error.value = null; + + try { + const response = await client.post('/api/calendar-integrations/sources', payload); + if (response.data) { + sources.value = [...sources.value, response.data] + .sort((left, right) => left.displayTitle.localeCompare(right.displayTitle)); + } + return response.data; + } catch (createError) { + console.error('Failed to create calendar source:', createError); + error.value = 'Failed to add calendar source.'; + throw createError; + } finally { + isCreatingSource.value = false; + } + } + + async function refreshSource(sourceId) { + if (!sourceId) { + return null; + } + + try { + const response = await client.post(`/api/calendar-integrations/sources/${sourceId}/refresh`); + const refreshedSource = response.data; + if (refreshedSource) { + sources.value = sources.value.map(source => + source.id === refreshedSource.id ? refreshedSource : source + ); + } + return refreshedSource; + } catch (refreshError) { + console.error('Failed to refresh calendar source:', refreshError); + error.value = 'Failed to refresh calendar source.'; + throw refreshError; + } + } + + function toggleSourceVisibility(sourceId) { + const nextHiddenIds = new Set(hiddenSourceIds.value); + if (nextHiddenIds.has(sourceId)) { + nextHiddenIds.delete(sourceId); + } else { + nextHiddenIds.add(sourceId); + } + hiddenSourceIds.value = nextHiddenIds; + } + + return { + sources, + events, + catalogEntries, + hiddenSourceIds, + visibleSourceIds, + visibleEvents, + isLoadingSources, + isLoadingEvents, + isLoadingCatalog, + isCreatingSource, + error, + sourceById, + fetchSources, + fetchEvents, + searchCatalog, + createSource, + refreshSource, + toggleSourceVisibility, + }; +}); diff --git a/frontend/src/features/content/stores/contentItemDetailStore.js b/frontend/src/features/content/stores/contentItemDetailStore.js index f83ac94..4b0a560 100644 --- a/frontend/src/features/content/stores/contentItemDetailStore.js +++ b/frontend/src/features/content/stores/contentItemDetailStore.js @@ -13,6 +13,7 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () = const comments = ref([]); const approvals = ref([]); const notifications = ref([]); + const activity = ref([]); const isLoading = ref(false); const error = ref(null); const actions = reactive({ @@ -35,6 +36,7 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () = comments.value = []; approvals.value = []; notifications.value = []; + activity.value = []; error.value = null; } @@ -49,19 +51,14 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () = assetsResponse, commentsResponse, approvalsResponse, - notificationsResponse, + activityResponse, ] = await Promise.all([ client.get(`/api/content-items/${contentItemId}`), client.get(`/api/content-items/${contentItemId}/revisions`), client.get('/api/assets', { params: { contentItemId } }), client.get('/api/comments', { params: { contentItemId } }), client.get('/api/approvals', { params: { contentItemId } }), - client.get('/api/notifications', { - params: { - workspaceId: workspaceStore.activeWorkspaceId ?? undefined, - contentItemId, - }, - }), + client.get(`/api/content-items/${contentItemId}/activity`), ]); item.value = itemResponse.data; @@ -69,7 +66,7 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () = assets.value = assetsResponse.data ?? []; comments.value = commentsResponse.data ?? []; approvals.value = approvalsResponse.data ?? []; - notifications.value = notificationsResponse.data ?? []; + activity.value = activityResponse.data ?? []; } catch (fetchError) { console.error('Failed to load content item detail:', fetchError); reset(); @@ -105,7 +102,7 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () = }); if (response.data) { assets.value = [...assets.value, response.data]; - await fetchNotifications(contentItemId); + await fetchActivity(contentItemId); } return response.data; } finally { @@ -120,7 +117,7 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () = const response = await client.post(`/api/assets/${assetId}/revisions`, payload); if (response.data) { await fetchAssets(contentItemId); - await fetchNotifications(contentItemId); + await fetchActivity(contentItemId); } return response.data; } finally { @@ -139,22 +136,7 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () = }); if (response.data) { comments.value = [...comments.value, response.data]; - await fetchNotifications(contentItemId); - } - return response.data; - } finally { - actions.comment = false; - } - } - - async function resolveComment(contentItemId, commentId) { - actions.comment = true; - - try { - const response = await client.post(`/api/comments/${commentId}/resolve`); - if (response.data) { - comments.value = comments.value.map(comment => comment.id === commentId ? response.data : comment); - await fetchNotifications(contentItemId); + await fetchActivity(contentItemId); } return response.data; } finally { @@ -170,7 +152,7 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () = if (response.data) { approvals.value = approvals.value.map(approval => approval.id === approvalId ? response.data : approval); await fetchContentItem(contentItemId); - await fetchNotifications(contentItemId); + await fetchActivity(contentItemId); } return response.data; } finally { @@ -184,7 +166,7 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () = try { const response = await client.post(`/api/content-items/${contentItemId}/status`, { status }); item.value = response.data; - await fetchNotifications(contentItemId); + await fetchActivity(contentItemId); return response.data; } finally { actions.status = false; @@ -214,6 +196,12 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () = return notifications.value; } + async function fetchActivity(contentItemId) { + const response = await client.get(`/api/content-items/${contentItemId}/activity`); + activity.value = response.data ?? []; + return activity.value; + } + return { item, revisions, @@ -221,6 +209,7 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () = comments, approvals, notifications, + activity, isLoading, error, actions, @@ -230,8 +219,8 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () = addGoogleDriveAsset, addAssetRevision, addComment, - resolveComment, submitDecision, updateStatus, + fetchActivity, }; }); diff --git a/frontend/src/features/content/views/ContentItemDetailView.vue b/frontend/src/features/content/views/ContentItemDetailView.vue index 2d2908b..e8b4de1 100644 --- a/frontend/src/features/content/views/ContentItemDetailView.vue +++ b/frontend/src/features/content/views/ContentItemDetailView.vue @@ -1,5 +1,6 @@
- -
{{ item.currentRevisionLabel }}
- {{ item.title }} - {{ item.publicationTargets }} -
- {{ item.status }} - {{ formatDueDate(item.dueDate) }} -
-
+ + + +
{{ contentItemsStore.items.find(item => item.id === entry.id)?.currentRevisionLabel }}
+ {{ entry.title }} + {{ contentItemsStore.items.find(item => item.id === entry.id)?.publicationTargets }} +
+ {{ contentItemsStore.items.find(item => item.id === entry.id)?.status }} + {{ formatEntryDate(entry.scheduledAt) }} +
+
+ + +
{{ t('dashboard.campaignDeadline') }}
+ {{ entry.title }} + {{ entry.subtitle }} +
+ {{ entry.timeLabel }} + {{ formatEntryDate(entry.scheduledAt) }} +
+
+
{{ t('contentItems.empty') }}
+ + +
+
+ {{ t('contentItems.calendar.addCalendar') }} + +
+ +
+ + +
+ +
+ +
+ +
+ + +
+ +
+
+ +
+ + +
+ + + +
+
+ +

+ {{ addCalendarError || calendarStore.error }} +

+
+
@@ -514,12 +1076,84 @@ color: #526178; } + .header-actions { + @apply flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-end; + } + .view-toggle { @apply inline-flex w-fit rounded-full border p-1; background: #f8fafc; border-color: rgba(23, 32, 51, 0.1); } + .calendar-selector { + @apply relative w-full sm:w-auto; + } + + .calendar-selector-button { + @apply inline-flex min-h-11 w-full items-center justify-between gap-2 rounded-full border px-4 py-2 text-sm font-bold transition sm:w-auto; + background: #ffffff; + border-color: rgba(23, 32, 51, 0.1); + color: #172033; + } + + .calendar-selector-button strong { + @apply rounded-full px-2 py-0.5 text-xs; + background: rgba(15, 118, 110, 0.1); + color: #0f766e; + } + + .calendar-selector-menu { + @apply absolute right-0 top-[calc(100%+0.5rem)] z-30 flex w-full min-w-72 flex-col gap-1 rounded-[1rem] border p-2 shadow-xl sm:w-80; + background: #ffffff; + border-color: rgba(23, 32, 51, 0.1); + } + + .calendar-selector-row, + .calendar-selector-add { + @apply flex min-h-11 w-full items-center gap-3 rounded-[0.75rem] px-3 text-left text-sm font-semibold transition; + color: #172033; + } + + .calendar-selector-row:hover, + .calendar-selector-add:hover { + background: #f8fafc; + } + + .calendar-selector-title { + @apply min-w-0 flex-1 truncate; + } + + .calendar-selector-empty { + @apply px-3 py-2 text-sm; + color: #526178; + } + + .calendar-selector-add { + @apply border-t; + border-color: rgba(23, 32, 51, 0.08); + color: #0f766e; + } + + .visibility-switch { + @apply relative h-6 w-10 shrink-0 rounded-full transition; + background: rgba(148, 163, 184, 0.35); + } + + .visibility-switch::after { + @apply absolute left-1 top-1 h-4 w-4 rounded-full bg-white transition; + content: ''; + box-shadow: 0 1px 4px rgba(23, 32, 51, 0.2); + } + + .visibility-switch.active { + background: #0f766e; + } + + .visibility-switch.active::after { + transform: translateX(1rem); + } + .toggle-button, .icon-button, .text-button { @@ -584,6 +1218,10 @@ color: #172033; } + .source-swatch { + @apply h-3 w-3 shrink-0 rounded-full; + } + .calendar-grid { @apply grid gap-3; grid-template-columns: repeat(7, minmax(0, 1fr)); @@ -631,6 +1269,11 @@ @apply flex flex-col gap-0.5 rounded-[1rem] border px-3 py-2 no-underline transition; } + button.calendar-entry, + button.item-card { + @apply w-full text-left; + } + .calendar-entry:hover, .item-card:hover { transform: translateY(-1px); @@ -690,6 +1333,105 @@ border-color: rgba(148, 163, 184, 0.18); } + .calendar-context-entry { + border-left-width: 4px; + background: #ffffff; + opacity: 0.86; + } + + .calendar-context-entry strong { + color: #334155; + } + + .calendar-upcoming-card { + border-left-width: 4px; + } + + .calendar-dialog { + @apply flex flex-col gap-4 rounded-[1.5rem] border bg-white p-5; + border-color: rgba(23, 32, 51, 0.1); + } + + .dialog-header, + .add-mode-toggle, + .scope-row, + .catalog-search, + .custom-form-row { + @apply flex flex-wrap items-center gap-3; + } + + .dialog-header { + @apply justify-between; + } + + .dialog-header strong { + @apply text-lg font-black; + color: #172033; + } + + .scope-option { + @apply inline-flex items-center gap-2 rounded-full border px-3 py-2 text-sm font-semibold; + border-color: rgba(23, 32, 51, 0.1); + color: #172033; + } + + .catalog-panel, + .custom-calendar-form, + .catalog-results { + @apply flex flex-col gap-3; + } + + .catalog-search input, + .custom-calendar-form input { + @apply min-h-11 rounded-[0.75rem] border px-3 text-sm; + border-color: rgba(23, 32, 51, 0.12); + color: #172033; + } + + .catalog-search input[type='search'], + .custom-calendar-form input[type='url'], + .custom-calendar-form input[type='text'] { + @apply min-w-0 flex-1; + } + + .catalog-results { + @apply max-h-[22rem] overflow-auto; + } + + .catalog-entry { + @apply grid min-h-14 grid-cols-[auto_minmax(0,1fr)] items-center gap-x-3 rounded-[0.75rem] border px-3 py-2 text-left transition; + border-color: rgba(23, 32, 51, 0.08); + background: #ffffff; + } + + .catalog-entry:hover { + background: #f8fafc; + } + + .catalog-entry-disabled { + cursor: not-allowed; + opacity: 0.58; + } + + .catalog-entry-disabled:hover { + background: #ffffff; + } + + .catalog-entry strong { + @apply text-sm font-bold; + color: #172033; + } + + .catalog-entry span:last-child { + @apply col-start-2 text-xs; + color: #526178; + } + + .dialog-error { + @apply text-sm font-semibold; + color: #b91c1c; + } + .item-grid { @apply grid gap-4 md:grid-cols-2 xl:grid-cols-3; } diff --git a/frontend/src/features/organizations/stores/organizationStore.js b/frontend/src/features/organizations/stores/organizationStore.js index 201f38c..8ffb738 100644 --- a/frontend/src/features/organizations/stores/organizationStore.js +++ b/frontend/src/features/organizations/stores/organizationStore.js @@ -22,11 +22,19 @@ export const useOrganizationStore = defineStore('organization', () => { const detailsById = ref({}); const isLoading = ref(false); const isLoadingDetails = ref(false); + const isSaving = ref(false); + const isAddingMember = ref(false); + const isUploadingLogo = ref(false); const error = ref(null); - const activeOrganization = computed(() => - organizations.value.find(organization => organization.id === selectedOrganizationId.value) ?? null - ); + const activeOrganization = computed(() => { + const organization = organizations.value.find(candidate => candidate.id === selectedOrganizationId.value) ?? null; + const details = selectedOrganizationId.value ? detailsById.value[selectedOrganizationId.value] : null; + + return organization || details + ? { ...(organization ?? {}), ...(details ?? {}) } + : null; + }); function userCan(organization, permission) { return Boolean(organization?.currentUserPermissions?.includes(permission)); @@ -109,6 +117,129 @@ export const useOrganizationStore = defineStore('organization', () => { } } + async function updateOrganization(organizationId, payload) { + if (!authStore.isAuthenticated || !organizationId) { + throw new Error('You must be authenticated to update an organization.'); + } + + isSaving.value = true; + error.value = null; + + try { + const response = await client.put(`/api/organizations/${organizationId}`, payload); + const organization = response.data; + + if (organization) { + const currentDetails = detailsById.value[organizationId]; + detailsById.value = { + ...detailsById.value, + [organizationId]: { + ...(currentDetails ?? {}), + ...organization, + members: currentDetails?.members ?? organization.members ?? [], + workspaces: currentDetails?.workspaces ?? organization.workspaces ?? [], + }, + }; + organizations.value = organizations.value.map(candidate => + candidate.id === organizationId + ? { ...candidate, ...organization } + : candidate + ); + } + + return organization; + } catch (updateError) { + console.error('Failed to update organization:', updateError); + error.value = 'Failed to update organization.'; + throw updateError; + } finally { + isSaving.value = false; + } + } + + async function addMember(organizationId, payload) { + if (!authStore.isAuthenticated || !organizationId) { + throw new Error('You must be authenticated to add an organization member.'); + } + + isAddingMember.value = true; + error.value = null; + + try { + const response = await client.post(`/api/organizations/${organizationId}/members`, payload); + const member = response.data; + + if (member) { + const currentDetails = detailsById.value[organizationId]; + if (currentDetails) { + detailsById.value = { + ...detailsById.value, + [organizationId]: { + ...currentDetails, + members: [...(currentDetails.members ?? []), member], + }, + }; + } + } + + return member; + } catch (addError) { + console.error('Failed to add organization member:', addError); + error.value = 'Failed to add organization member.'; + throw addError; + } finally { + isAddingMember.value = false; + } + } + + async function uploadLogo(organizationId, file) { + if (!authStore.isAuthenticated || !organizationId) { + throw new Error('You must be authenticated to upload an organization logo.'); + } + + if (isUploadingLogo.value) { + throw new Error('An organization logo upload is already in progress.'); + } + + isUploadingLogo.value = true; + error.value = null; + + try { + const formData = new FormData(); + formData.append('file', file, file.name || 'organization-logo.png'); + + const response = await client.post(`/api/organizations/${organizationId}/logo`, formData); + const blobUrl = response.data?.blobUrl; + + if (blobUrl) { + const logoUrl = `${blobUrl}?${Date.now()}`; + const currentDetails = detailsById.value[organizationId]; + if (currentDetails) { + detailsById.value = { + ...detailsById.value, + [organizationId]: { + ...currentDetails, + logoUrl, + }, + }; + } + organizations.value = organizations.value.map(organization => + organization.id === organizationId + ? { ...organization, logoUrl } + : organization + ); + } + + return response.data; + } catch (uploadError) { + console.error('Failed to upload organization logo:', uploadError); + error.value = 'Failed to upload organization logo.'; + throw uploadError; + } finally { + isUploadingLogo.value = false; + } + } + watch( () => authStore.isAuthenticated, async isAuthenticated => { @@ -132,11 +263,17 @@ export const useOrganizationStore = defineStore('organization', () => { detailsById, isLoading, isLoadingDetails, + isSaving, + isAddingMember, + isUploadingLogo, error, userCan, setSelectedOrganization, setSelectedOrganizationFromWorkspace, fetchOrganizations, fetchOrganization, + updateOrganization, + addMember, + uploadLogo, }; }); diff --git a/frontend/src/features/organizations/views/OrganizationSettingsView.vue b/frontend/src/features/organizations/views/OrganizationSettingsView.vue index b6aede7..8817752 100644 --- a/frontend/src/features/organizations/views/OrganizationSettingsView.vue +++ b/frontend/src/features/organizations/views/OrganizationSettingsView.vue @@ -1,26 +1,41 @@