Add calendar integrations and collaboration updates
This commit is contained in:
@@ -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<Campaign> Campaigns => Set<Campaign>();
|
||||
public DbSet<ContentItem> ContentItems => Set<ContentItem>();
|
||||
public DbSet<ContentItemRevision> ContentItemRevisions => Set<ContentItemRevision>();
|
||||
public DbSet<ContentItemActivityEntry> ContentItemActivityEntries => Set<ContentItemActivityEntry>();
|
||||
public DbSet<Asset> Assets => Set<Asset>();
|
||||
public DbSet<AssetRevision> AssetRevisions => Set<AssetRevision>();
|
||||
public DbSet<Comment> Comments => Set<Comment>();
|
||||
@@ -41,6 +43,10 @@ public class AppDbContext(
|
||||
public DbSet<FeedbackScreenshot> FeedbackScreenshots => Set<FeedbackScreenshot>();
|
||||
public DbSet<FeedbackComment> FeedbackComments => Set<FeedbackComment>();
|
||||
public DbSet<FeedbackActivityEntry> FeedbackActivityEntries => Set<FeedbackActivityEntry>();
|
||||
public DbSet<CalendarSource> CalendarSources => Set<CalendarSource>();
|
||||
public DbSet<CalendarCatalogEntry> CalendarCatalogEntries => Set<CalendarCatalogEntry>();
|
||||
public DbSet<CalendarEvent> CalendarEvents => Set<CalendarEvent>();
|
||||
public DbSet<UserCalendarExportFeed> UserCalendarExportFeeds => Set<UserCalendarExportFeed>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
{
|
||||
@@ -57,5 +63,6 @@ public class AppDbContext(
|
||||
builder.ConfigureApprovalsModule();
|
||||
builder.ConfigureNotificationsModule();
|
||||
builder.ConfigureFeedbackModule();
|
||||
builder.ConfigureCalendarIntegrationsModule();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,50 +0,0 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Socialize.Api.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddChannels : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Channels",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
WorkspaceId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||
Network = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
Handle = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||
ExternalUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "Channels");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("Country")
|
||||
.HasMaxLength(2)
|
||||
.HasColumnType("character varying(2)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("CultureOrReligion")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("DefaultColor")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)");
|
||||
|
||||
b.Property<string>("ProviderName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("Region")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("SourceUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("CalendarSourceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("character varying(4000)");
|
||||
|
||||
b.Property<DateOnly>("EndDate")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<DateTime?>("EndLocalDateTime")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("EndUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("ImportedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("IsAllDay")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsFloatingTime")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Location")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)");
|
||||
|
||||
b.Property<string>("RecurrenceId")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)");
|
||||
|
||||
b.Property<string>("SourceEventUid")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)");
|
||||
|
||||
b.Property<DateTimeOffset?>("SourceLastModifiedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("SourceUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<DateOnly>("StartDate")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<DateTime?>("StartLocalDateTime")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("StartUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("TimeZoneId")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("CatalogSourceReference")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("Color")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("DisplayTitle")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("InheritanceMode")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastAttemptedSyncAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastSuccessfulSyncAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("LastSyncError")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<Guid?>("OrganizationId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Scope")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("SourceUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<Guid?>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<DateTimeOffset?>("RevokedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Token")
|
||||
.HasMaxLength(96)
|
||||
.HasColumnType("character varying(96)");
|
||||
|
||||
b.Property<string>("TokenHash")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
@@ -618,15 +938,9 @@ namespace Socialize.Api.Migrations
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<bool>("IsResolved")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<Guid?>("ParentCommentId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("ResolvedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ActorEmail")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<Guid?>("ActorUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("ContentItemId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<Guid>("EntityId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("EntityType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("EventType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("MetadataJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("Summary")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
@@ -1259,6 +1629,10 @@ namespace Socialize.Api.Migrations
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("LogoUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<string>("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")
|
||||
@@ -4,6 +4,8 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||
|
||||
namespace Socialize.Api.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
@@ -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<Guid>(type: "uuid", nullable: false),
|
||||
Title = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||
Description = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
|
||||
Country = table.Column<string>(type: "character varying(2)", maxLength: 2, nullable: true),
|
||||
Region = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
|
||||
Language = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
|
||||
Category = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
CultureOrReligion = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
|
||||
ProviderName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||
SourceUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: false),
|
||||
TrustLevel = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
DefaultColor = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(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<Guid>(type: "uuid", nullable: false),
|
||||
Scope = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
OrganizationId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
WorkspaceId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
SourceUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
|
||||
CatalogSourceReference = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||
DisplayTitle = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||
Color = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
|
||||
Category = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
IsEnabled = table.Column<bool>(type: "boolean", nullable: false),
|
||||
InheritanceMode = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
|
||||
LastSuccessfulSyncAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
LastAttemptedSyncAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
LastSyncError = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
|
||||
UpdatedAt = table.Column<DateTimeOffset>(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<Guid>(type: "uuid", nullable: false),
|
||||
WorkspaceId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||
Network = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
Handle = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||
ExternalUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(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<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||
AuthorEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||
Body = table.Column<string>(type: "character varying(4000)", maxLength: 4000, nullable: false),
|
||||
IsResolved = table.Column<bool>(type: "boolean", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
|
||||
ResolvedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
|
||||
CreatedAt = table.Column<DateTimeOffset>(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<Guid>(type: "uuid", nullable: false),
|
||||
WorkspaceId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ContentItemId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
EventType = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||
EntityType = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||
EntityId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Summary = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
|
||||
ActorUserId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
ActorEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||
MetadataJson = table.Column<string>(type: "jsonb", nullable: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(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<Guid>(type: "uuid", nullable: false),
|
||||
Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||
LogoUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
|
||||
OwnerUserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(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<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Token = table.Column<string>(type: "character varying(96)", maxLength: 96, nullable: true),
|
||||
TokenHash = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
|
||||
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
|
||||
RevokedAt = table.Column<DateTimeOffset>(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<Guid>(type: "uuid", nullable: false),
|
||||
CalendarSourceId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
SourceEventUid = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false),
|
||||
Title = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false),
|
||||
Description = table.Column<string>(type: "character varying(4000)", maxLength: 4000, nullable: true),
|
||||
IsAllDay = table.Column<bool>(type: "boolean", nullable: false),
|
||||
IsFloatingTime = table.Column<bool>(type: "boolean", nullable: false),
|
||||
StartDate = table.Column<DateOnly>(type: "date", nullable: false),
|
||||
EndDate = table.Column<DateOnly>(type: "date", nullable: false),
|
||||
StartLocalDateTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
EndLocalDateTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
StartUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
EndUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
TimeZoneId = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
|
||||
RecurrenceId = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true),
|
||||
Location = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true),
|
||||
SourceUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
|
||||
SourceLastModifiedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
ImportedAt = table.Column<DateTimeOffset>(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");
|
||||
|
||||
@@ -438,6 +438,326 @@ namespace Socialize.Api.Migrations
|
||||
b.ToTable("AssetRevisions", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Socialize.Api.Modules.CalendarIntegrations.Data.CalendarCatalogEntry", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("Country")
|
||||
.HasMaxLength(2)
|
||||
.HasColumnType("character varying(2)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("CultureOrReligion")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("DefaultColor")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)");
|
||||
|
||||
b.Property<string>("ProviderName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("Region")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("SourceUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("CalendarSourceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("character varying(4000)");
|
||||
|
||||
b.Property<DateOnly>("EndDate")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<DateTime?>("EndLocalDateTime")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("EndUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("ImportedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("IsAllDay")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsFloatingTime")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Location")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)");
|
||||
|
||||
b.Property<string>("RecurrenceId")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)");
|
||||
|
||||
b.Property<string>("SourceEventUid")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)");
|
||||
|
||||
b.Property<DateTimeOffset?>("SourceLastModifiedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("SourceUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<DateOnly>("StartDate")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<DateTime?>("StartLocalDateTime")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("StartUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("TimeZoneId")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("CatalogSourceReference")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("Color")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("DisplayTitle")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("InheritanceMode")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastAttemptedSyncAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastSuccessfulSyncAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("LastSyncError")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<Guid?>("OrganizationId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Scope")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("SourceUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<Guid?>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<DateTimeOffset?>("RevokedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Token")
|
||||
.HasMaxLength(96)
|
||||
.HasColumnType("character varying(96)");
|
||||
|
||||
b.Property<string>("TokenHash")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
@@ -615,15 +935,9 @@ namespace Socialize.Api.Migrations
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<bool>("IsResolved")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<Guid?>("ParentCommentId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("ResolvedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ActorEmail")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<Guid?>("ActorUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("ContentItemId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<Guid>("EntityId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("EntityType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("EventType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("MetadataJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("Summary")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
@@ -1256,6 +1626,10 @@ namespace Socialize.Api.Migrations
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("LogoUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<string>("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")
|
||||
|
||||
@@ -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<SubmitApprovalDecisionRequest, ApprovalRequestDto>
|
||||
{
|
||||
@@ -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,
|
||||
|
||||
@@ -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<CreateAssetRevisionRequest, AssetRevisionDto>
|
||||
{
|
||||
@@ -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,
|
||||
|
||||
@@ -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<CreateGoogleDriveAssetRequest, AssetDto>
|
||||
{
|
||||
@@ -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,
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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<CalendarSource>(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<CalendarCatalogEntry>(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 =>
|
||||
{
|
||||
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<CalendarSource>()
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.CalendarSourceId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<UserCalendarExportFeed>(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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Socialize.Api.Modules.CalendarIntegrations;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static WebApplicationBuilder AddCalendarIntegrationsModule(this WebApplicationBuilder builder)
|
||||
{
|
||||
builder.Services.AddSingleton<Services.IcsCalendarParser>();
|
||||
builder.Services.AddSingleton<Services.CalendarExportFeedBuilder>();
|
||||
builder.Services.AddScoped<Services.CalendarExportFeedService>();
|
||||
builder.Services.AddScoped<Services.CalendarImportSyncService>();
|
||||
builder.Services.AddHostedService<Services.CalendarImportBackgroundService>();
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
@@ -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<UpsertCalendarSourceRequest>
|
||||
{
|
||||
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.");
|
||||
}
|
||||
}
|
||||
@@ -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<UpsertCalendarSourceRequest, CalendarSourceDto>
|
||||
{
|
||||
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<bool> 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<bool> SourceAlreadyExistsAsync(
|
||||
string scope,
|
||||
Guid? organizationId,
|
||||
Guid? workspaceId,
|
||||
Guid currentUserId,
|
||||
string? sourceUrl,
|
||||
string? catalogSourceReference,
|
||||
CancellationToken ct)
|
||||
{
|
||||
IQueryable<CalendarSource> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<Guid>("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<bool> 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<ListCalendarCatalogRequest, IReadOnlyCollection<CalendarCatalogEntryDto>>
|
||||
{
|
||||
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<CalendarCatalogEntry> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<ListCalendarEventsRequest, IReadOnlyCollection<CalendarEventDto>>
|
||||
{
|
||||
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<CalendarSource> 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<Guid> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<ListCalendarSourcesRequest, IReadOnlyCollection<CalendarSourceDto>>
|
||||
{
|
||||
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<CalendarSource> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<CalendarSourceDto>
|
||||
{
|
||||
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<Guid>("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<bool> 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<UpsertCalendarSourceRequest, CalendarSourceDto>
|
||||
{
|
||||
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<Guid>("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<bool> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<UserCalendarExportFeedDto>
|
||||
{
|
||||
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<UserCalendarExportFeedDto>
|
||||
{
|
||||
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<UserCalendarExportFeedDto>
|
||||
{
|
||||
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<UserCalendarExportFeedDto>
|
||||
{
|
||||
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<string?>("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";
|
||||
}
|
||||
}
|
||||
@@ -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<CalendarExportFeedEvent> 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(",", "\\,");
|
||||
}
|
||||
}
|
||||
@@ -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<string> 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<CalendarExportFeedEvent> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace Socialize.Api.Modules.CalendarIntegrations.Services;
|
||||
|
||||
public static class CalendarExportFeedTokenService
|
||||
{
|
||||
public static string GenerateToken()
|
||||
{
|
||||
Span<byte> 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<byte> bytes)
|
||||
{
|
||||
return Convert.ToBase64String(bytes)
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace Socialize.Api.Modules.CalendarIntegrations.Services;
|
||||
|
||||
public sealed class CalendarImportBackgroundService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<CalendarImportBackgroundService> 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<CalendarImportSyncService>();
|
||||
await syncService.RefreshDueSourcesAsync(stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Calendar import background sync failed.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ParsedCalendarEvent> 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<ParsedCalendarEvent> 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<IReadOnlyCollection<ParsedCalendarEvent>> 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<IReadOnlyCollection<ParsedCalendarEvent>> GetNagerEventsAsync(
|
||||
HttpClient httpClient,
|
||||
string sourceUrl,
|
||||
string countryCode,
|
||||
DateOnly rangeStart,
|
||||
DateOnly rangeEnd,
|
||||
CancellationToken ct)
|
||||
{
|
||||
List<ParsedCalendarEvent> 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<NagerHoliday[]>(
|
||||
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<ParsedCalendarEvent> 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);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<ParsedCalendarEvent> Parse(
|
||||
string content,
|
||||
DateOnly rangeStart,
|
||||
DateOnly rangeEnd)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
|
||||
List<ParsedCalendarEvent> 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<IcsRawEvent> ReadRawEvents(string content)
|
||||
{
|
||||
List<string> lines = UnfoldLines(content).ToList();
|
||||
for (int index = 0; index < lines.Count; index++)
|
||||
{
|
||||
if (!lines[index].Equals("BEGIN:VEVENT", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Dictionary<string, List<(Dictionary<string, string> 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<string> 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<string, List<(Dictionary<string, string> 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<string, string> 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<string, List<(Dictionary<string, string> Parameters, string Value)>> properties,
|
||||
string key,
|
||||
out (Dictionary<string, string> 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<string, List<(Dictionary<string, string> Parameters, string Value)>> properties,
|
||||
string key)
|
||||
{
|
||||
return TryGetFirst(properties, key, out var value) ? value.Value : null;
|
||||
}
|
||||
|
||||
private static IcsDateTimeValue ParseDateTimeValue(
|
||||
string value,
|
||||
Dictionary<string, string> 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<ParsedCalendarEvent> Expand(
|
||||
IcsRawEvent rawEvent,
|
||||
DateOnly rangeStart,
|
||||
DateOnly rangeEnd)
|
||||
{
|
||||
TimeSpan duration = GetDuration(rawEvent.Start, rawEvent.End);
|
||||
IReadOnlyCollection<DateOnly> 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<DateOnly> ExpandStartDates(
|
||||
IcsRawEvent rawEvent,
|
||||
DateOnly rangeStart,
|
||||
DateOnly rangeEnd)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawEvent.RRule))
|
||||
{
|
||||
return IsInRange(rawEvent.Start.Date, rangeStart, rangeEnd) ? [rawEvent.Start.Date] : [];
|
||||
}
|
||||
|
||||
Dictionary<string, string> 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<DateOnly> 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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<CreateCommentRequest, CommentDto>
|
||||
{
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<CommentDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/comments/{id}/resolve");
|
||||
Options(o => o.WithTags("Comments"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
Guid id = Route<Guid>("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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -41,6 +41,23 @@ public static class ContentItemModelConfiguration
|
||||
revision.HasIndex(x => new { x.ContentItemId, x.RevisionNumber }).IsUnique();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<ContentItemActivityEntry>(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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<IContentItemActivityWriter, ContentItemActivityWriter>();
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<CreateContentItemRequest, ContentItemDto>
|
||||
{
|
||||
@@ -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,
|
||||
|
||||
@@ -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<CreateContentItemRevisionRequest>
|
||||
@@ -30,6 +33,7 @@ public class CreateContentItemRevisionRequestValidator
|
||||
public class CreateContentItemRevisionHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService,
|
||||
IContentItemActivityWriter activityWriter,
|
||||
INotificationEventWriter notificationEventWriter)
|
||||
: Endpoint<CreateContentItemRevisionRequest, ContentItemRevisionDto>
|
||||
{
|
||||
@@ -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<object> 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<T>(List<object> changedFields, string field, T oldValue, T newValue)
|
||||
{
|
||||
if (EqualityComparer<T>.Default.Equals(oldValue, newValue))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
changedFields.Add(new
|
||||
{
|
||||
field,
|
||||
oldValue,
|
||||
newValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<IReadOnlyCollection<ContentItemActivityEntryDto>>
|
||||
{
|
||||
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<Guid>("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<ContentItemActivityEntryDto> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<UpdateContentItemStatusRequest, ContentItemDetailDto>
|
||||
{
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<AddOrganizationMemberRequest>
|
||||
{
|
||||
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<AddOrganizationMemberRequest, OrganizationMemberDto>
|
||||
{
|
||||
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<Guid>("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();
|
||||
}
|
||||
}
|
||||
@@ -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<ChangeOrganizationLogoRequest>
|
||||
{
|
||||
public ChangeOrganizationLogoRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.File)
|
||||
.NotNull()
|
||||
.NotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
public class ChangeOrganizationLogoHandler(
|
||||
AppDbContext dbContext,
|
||||
IBlobStorage blobStorage,
|
||||
OrganizationAccessService organizationAccessService)
|
||||
: Endpoint<ChangeOrganizationLogoRequest, ChangeOrganizationLogoResponse>
|
||||
{
|
||||
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<Guid>("organizationId");
|
||||
|
||||
Organization? organization = await dbContext.Organizations
|
||||
.SingleOrDefaultAsync(candidate => candidate.Id == organizationId, ct);
|
||||
if (organization is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await organizationAccessService.HasOrganizationPermissionAsync(
|
||||
User,
|
||||
organizationId,
|
||||
OrganizationPermissions.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);
|
||||
}
|
||||
}
|
||||
@@ -44,13 +44,15 @@ public class GetOrganizationHandler(
|
||||
|
||||
IReadOnlyCollection<OrganizationMemberDto> members = await GetMembersAsync(organizationId, ct);
|
||||
IReadOnlyCollection<WorkspaceDto> 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<OrganizationUsageDto> 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))
|
||||
|
||||
@@ -15,25 +15,39 @@ public record OrganizationMemberDto(
|
||||
public record OrganizationDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string? LogoUrl,
|
||||
Guid OwnerUserId,
|
||||
IReadOnlyCollection<string> CurrentUserPermissions,
|
||||
IReadOnlyCollection<OrganizationMemberDto> Members,
|
||||
IReadOnlyCollection<WorkspaceDto> Workspaces,
|
||||
OrganizationUsageDto? Usage,
|
||||
DateTimeOffset CreatedAt)
|
||||
{
|
||||
public static OrganizationDto FromOrganization(
|
||||
Organization organization,
|
||||
IReadOnlyCollection<string> currentUserPermissions,
|
||||
IReadOnlyCollection<OrganizationMemberDto>? members = null,
|
||||
IReadOnlyCollection<WorkspaceDto>? workspaces = null)
|
||||
IReadOnlyCollection<WorkspaceDto>? 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<OrganizationUsageItemDto> Items);
|
||||
|
||||
public record OrganizationUsageItemDto(
|
||||
string Key,
|
||||
int Used,
|
||||
int? Limit);
|
||||
|
||||
@@ -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<UpdateOrganizationRequest>
|
||||
{
|
||||
public UpdateOrganizationRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
|
||||
}
|
||||
}
|
||||
|
||||
public class UpdateOrganizationHandler(
|
||||
AppDbContext dbContext,
|
||||
OrganizationAccessService organizationAccessService)
|
||||
: Endpoint<UpdateOrganizationRequest, OrganizationDto>
|
||||
{
|
||||
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<Guid>("organizationId");
|
||||
|
||||
Organization? organization = await dbContext.Organizations
|
||||
.SingleOrDefaultAsync(candidate => candidate.Id == organizationId, ct);
|
||||
if (organization is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await organizationAccessService.HasOrganizationPermissionAsync(
|
||||
User,
|
||||
organizationId,
|
||||
OrganizationPermissions.ManageOrganizationSettings,
|
||||
ct))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
organization.Name = request.Name.Trim();
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
|
||||
IReadOnlyCollection<string> currentUserPermissions = await organizationAccessService.GetUserOrganizationPermissionsAsync(
|
||||
User,
|
||||
organizationId,
|
||||
ct);
|
||||
|
||||
await SendOkAsync(OrganizationDto.FromOrganization(organization, currentUserPermissions), ct);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<ParsedCalendarEvent> 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user