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);
|
||||
}
|
||||
}
|
||||
@@ -154,15 +154,9 @@ Each calendar source has a configurable color.
|
||||
|
||||
## Calendar Source Control
|
||||
|
||||
The Content calendar includes a compact calendar source control.
|
||||
The Content calendar includes a compact calendar source dropdown placed next to the calendar view selector.
|
||||
|
||||
The control lists currently displayed calendar sources, grouped or labeled by:
|
||||
|
||||
- Organization
|
||||
- Workspace
|
||||
- My calendars
|
||||
|
||||
Each source has a visibility toggle. Inherited read-only sources still appear in this list. The last entry is `Add calendar`.
|
||||
The dropdown lists currently displayed calendar sources with each source's visibility toggle. Inherited read-only sources still appear in this list. The last entry is `Add calendar`.
|
||||
|
||||
The `Add calendar` flow lets users search the curated catalog or add a custom `.ics` URL, subject to their permissions and selected scope.
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ Show imported calendar events and calendar source controls in the Content calend
|
||||
- Add calendar source data loading to the Content feature.
|
||||
- Render imported events as read-only calendar context entries in Month, Week, and Upcoming views.
|
||||
- Style imported events distinctly from Socialize content items and apply source colors.
|
||||
- Add a calendar source control grouped by Organization, Workspace, and My calendars.
|
||||
- Add a compact calendar source dropdown next to the Content calendar view selector.
|
||||
- Add visibility toggles for displayed sources.
|
||||
- Add `Add calendar` as the last source-control entry.
|
||||
- Add an add-calendar flow that supports curated catalog search and custom `.ics` URL subscriptions according to user permissions.
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# Task: Add content production collaboration panel
|
||||
|
||||
## Feature
|
||||
|
||||
`docs/FEATURES/production-workflow.md`
|
||||
|
||||
## Goal
|
||||
|
||||
Make the content detail page expose the existing production collaboration data that is already loaded by the frontend store: comments, content revisions, linked assets, asset revisions, and workflow activity.
|
||||
|
||||
## Scope
|
||||
|
||||
- Add a compact production collaboration panel to `ContentItemDetailView`.
|
||||
- Keep comment creation and resolution available.
|
||||
- Show content revision history with change summaries.
|
||||
- Show linked assets and their revisions.
|
||||
- Add UI for linking a Google Drive asset and adding a new asset revision.
|
||||
- Show content-scoped notification activity as a read-only production activity feed.
|
||||
|
||||
## Likely Files
|
||||
|
||||
- `frontend/src/features/content/views/ContentItemDetailView.vue`
|
||||
- `frontend/src/features/content/stores/contentItemDetailStore.js`
|
||||
|
||||
## Out Of Scope
|
||||
|
||||
- Backend schema changes.
|
||||
- Native file uploads.
|
||||
- Mention parsing.
|
||||
- Approval comment visibility rules.
|
||||
- Reworking content variant persistence.
|
||||
|
||||
## Validation
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] Comments, revisions, assets, and activity are visible from the content detail page.
|
||||
- [x] Users can post comments from the production panel.
|
||||
- [x] Users can link a Google Drive asset to a content item.
|
||||
- [x] Users can add a new revision URL/reference to an existing asset.
|
||||
- [x] Existing create/edit content and approval controls remain available.
|
||||
32
docs/TASKS/content/005-scope-content-editor-channels.md
Normal file
32
docs/TASKS/content/005-scope-content-editor-channels.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# Task: Scope content editor channels to item workspace
|
||||
|
||||
## Feature
|
||||
|
||||
`docs/FEATURES/channels.md`
|
||||
|
||||
## Goal
|
||||
|
||||
Prevent content item channel placements from mixing channels across workspaces when the app is viewed in an all-workspaces scope.
|
||||
|
||||
## Context
|
||||
|
||||
Seeded content such as `Bakery loyalty carousel` should only use channels from its own workspace. The seed source assigns it to `Atlas Bakery Instagram`, but stale editor drafts or all-workspaces channel options can show unrelated Luma channels in the content detail editor.
|
||||
|
||||
## Scope
|
||||
|
||||
- Limit content detail channel options to the content item's workspace.
|
||||
- Deduplicate publication target parsing and summary serialization.
|
||||
- Normalize restored editor drafts so duplicate or other-workspace known channels are not kept.
|
||||
|
||||
## Validation
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] Existing content items only offer channels from their own workspace.
|
||||
- [x] Duplicate publication target strings render as one placement.
|
||||
- [x] Stale restored drafts do not keep known channels from other workspaces.
|
||||
40
docs/TASKS/content/006-content-activity-endpoint.md
Normal file
40
docs/TASKS/content/006-content-activity-endpoint.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Task: Add content activity endpoint
|
||||
|
||||
## Feature
|
||||
|
||||
`docs/FEATURES/production-workflow.md`
|
||||
|
||||
## Goal
|
||||
|
||||
Add a content-owned activity history endpoint that is separate from user-facing notifications.
|
||||
|
||||
## Scope
|
||||
|
||||
- Add persisted content activity entries.
|
||||
- Add `GET /api/content-items/{id}/activity`.
|
||||
- Log content creation, revisions, status changes, comments, linked assets, and asset revisions.
|
||||
- Include field-level metadata for content revision changes such as title, message, channels, hashtags, and publish date.
|
||||
- Persist publish date changes sent from the content editor revision flow.
|
||||
- Use the activity endpoint from the content detail production activity tab.
|
||||
|
||||
## Out Of Scope
|
||||
|
||||
- Full diff rendering UI.
|
||||
- Deleting tags or assets.
|
||||
- Notification recipient behavior changes.
|
||||
|
||||
## Validation
|
||||
|
||||
```bash
|
||||
dotnet build backend/Socialize.slnx
|
||||
cd frontend
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] `GET /api/content-items/{id}/activity` returns content history for users who can review the content item.
|
||||
- [x] Activity entries are not filtered by notification recipients.
|
||||
- [x] Content revision activity records changed fields.
|
||||
- [x] Publish date changes are saved and included in content activity.
|
||||
- [x] The content detail activity tab reads from content activity instead of notifications.
|
||||
@@ -0,0 +1,46 @@
|
||||
# Task: Organization settings editing
|
||||
|
||||
## Feature
|
||||
|
||||
`docs/FEATURES/organizations.md`
|
||||
|
||||
## Goal
|
||||
|
||||
Allow permitted organization users to edit the organization name and add organization members from the settings page.
|
||||
|
||||
## Scope
|
||||
|
||||
- Add an API endpoint to update organization profile settings.
|
||||
- Add an API endpoint to add an existing user as an organization member by email and role.
|
||||
- Add organization logo storage and an API endpoint to upload a cropped organization logo.
|
||||
- Replace the organization Workspaces settings tab with a Usage tab.
|
||||
- Wire the organization settings profile section to save the organization name.
|
||||
- Wire the organization settings profile section to change the organization logo.
|
||||
- Show current organization usage against preview plan limits.
|
||||
- Wire the organization settings members section to add members.
|
||||
- Add English and French UI strings.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Do not implement email delivery or pending organization invitation tokens in this task.
|
||||
- Do not change workspace invite behavior.
|
||||
- Keep organization code under `backend/src/Socialize.Api/Modules/Organizations`.
|
||||
- Keep frontend organization code under `frontend/src/features/organizations`.
|
||||
|
||||
## Done When
|
||||
|
||||
- [x] Users with `ManageOrganizationSettings` can update the organization name.
|
||||
- [x] Users with `ManageOrganizationSettings` can update the organization logo.
|
||||
- [x] Organization settings show Usage instead of Workspaces.
|
||||
- [x] Users with `ManageOrganizationMembers` can add an existing user as an organization member.
|
||||
- [x] Duplicate organization memberships are rejected.
|
||||
- [x] Frontend build passes.
|
||||
- [x] Backend build passes.
|
||||
|
||||
## Validation Commands
|
||||
|
||||
```bash
|
||||
dotnet build backend/Socialize.slnx
|
||||
cd frontend
|
||||
npm run build
|
||||
```
|
||||
343
frontend/src/api/schema.d.ts
vendored
343
frontend/src/api/schema.d.ts
vendored
@@ -100,6 +100,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/organizations/{organizationId}/members": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["SocializeApiModulesOrganizationsHandlersAddOrganizationMemberHandler"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/organizations/{organizationId}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -108,7 +124,7 @@ export interface paths {
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["SocializeApiModulesOrganizationsHandlersGetOrganizationHandler"];
|
||||
put?: never;
|
||||
put: operations["SocializeApiModulesOrganizationsHandlersUpdateOrganizationHandler"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
@@ -820,6 +836,38 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/calendar-integrations/sources": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["SocializeApiModulesCalendarIntegrationsHandlersListCalendarSourcesHandler"];
|
||||
put?: never;
|
||||
post: operations["SocializeApiModulesCalendarIntegrationsHandlersCreateCalendarSourceHandler"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/calendar-integrations/sources/{sourceId}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put: operations["SocializeApiModulesCalendarIntegrationsHandlersUpdateCalendarSourceHandler"];
|
||||
post?: never;
|
||||
delete: operations["SocializeApiModulesCalendarIntegrationsHandlersDeleteCalendarSourceHandler"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/assets/{id}/revisions": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1003,6 +1051,22 @@ export interface components {
|
||||
/** Format: int32 */
|
||||
requiredApproverCount?: number;
|
||||
};
|
||||
SocializeApiModulesOrganizationsHandlersOrganizationMemberDto: {
|
||||
/** Format: guid */
|
||||
userId?: string;
|
||||
displayName?: string;
|
||||
email?: string;
|
||||
portraitUrl?: string | null;
|
||||
role?: string;
|
||||
permissions?: string[];
|
||||
/** Format: date-time */
|
||||
createdAt?: string;
|
||||
};
|
||||
SocializeApiModulesOrganizationsHandlersAddOrganizationMemberRequest: {
|
||||
/** Format: email */
|
||||
email: string;
|
||||
role: string;
|
||||
};
|
||||
SocializeApiModulesOrganizationsHandlersOrganizationDto: {
|
||||
/** Format: guid */
|
||||
id?: string;
|
||||
@@ -1015,16 +1079,8 @@ export interface components {
|
||||
/** Format: date-time */
|
||||
createdAt?: string;
|
||||
};
|
||||
SocializeApiModulesOrganizationsHandlersOrganizationMemberDto: {
|
||||
/** Format: guid */
|
||||
userId?: string;
|
||||
displayName?: string;
|
||||
email?: string;
|
||||
portraitUrl?: string | null;
|
||||
role?: string;
|
||||
permissions?: string[];
|
||||
/** Format: date-time */
|
||||
createdAt?: string;
|
||||
SocializeApiModulesOrganizationsHandlersUpdateOrganizationRequest: {
|
||||
name: string;
|
||||
};
|
||||
SocializeApiModulesNotificationsHandlersNotificationEventDto: {
|
||||
/** Format: guid */
|
||||
@@ -1478,6 +1534,49 @@ export interface components {
|
||||
notes?: string | null;
|
||||
};
|
||||
SocializeApiModulesCampaignsHandlersGetCampaignsRequest: Record<string, never>;
|
||||
SocializeApiModulesCalendarIntegrationsHandlersCalendarSourceDto: {
|
||||
/** Format: guid */
|
||||
id?: string;
|
||||
scope?: string;
|
||||
/** Format: guid */
|
||||
organizationId?: string | null;
|
||||
/** Format: guid */
|
||||
workspaceId?: string | null;
|
||||
/** Format: guid */
|
||||
userId?: string | null;
|
||||
sourceUrl?: string | null;
|
||||
catalogSourceReference?: string | null;
|
||||
displayTitle?: string;
|
||||
color?: string;
|
||||
category?: string;
|
||||
isEnabled?: boolean;
|
||||
inheritanceMode?: string | null;
|
||||
isReadOnly?: boolean;
|
||||
/** Format: date-time */
|
||||
lastSuccessfulSyncAt?: string | null;
|
||||
/** Format: date-time */
|
||||
lastAttemptedSyncAt?: string | null;
|
||||
lastSyncError?: string | null;
|
||||
/** Format: date-time */
|
||||
createdAt?: string;
|
||||
/** Format: date-time */
|
||||
updatedAt?: string;
|
||||
};
|
||||
SocializeApiModulesCalendarIntegrationsHandlersUpsertCalendarSourceRequest: {
|
||||
scope: string;
|
||||
/** Format: guid */
|
||||
organizationId?: string | null;
|
||||
/** Format: guid */
|
||||
workspaceId?: string | null;
|
||||
sourceUrl?: string | null;
|
||||
catalogSourceReference?: string | null;
|
||||
displayTitle: string;
|
||||
color: string;
|
||||
category: string;
|
||||
isEnabled?: boolean;
|
||||
inheritanceMode?: string | null;
|
||||
};
|
||||
SocializeApiModulesCalendarIntegrationsHandlersListCalendarSourcesRequest: Record<string, never>;
|
||||
SocializeApiModulesAssetsHandlersAssetRevisionDto: {
|
||||
/** Format: guid */
|
||||
id?: string;
|
||||
@@ -1860,6 +1959,48 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
SocializeApiModulesOrganizationsHandlersAddOrganizationMemberHandler: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
organizationId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["SocializeApiModulesOrganizationsHandlersAddOrganizationMemberRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Success */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["SocializeApiModulesOrganizationsHandlersOrganizationMemberDto"];
|
||||
};
|
||||
};
|
||||
/** @description Bad Request */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["FastEndpointsErrorResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
SocializeApiModulesOrganizationsHandlersGetOrganizationHandler: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1889,6 +2030,48 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
SocializeApiModulesOrganizationsHandlersUpdateOrganizationHandler: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
organizationId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["SocializeApiModulesOrganizationsHandlersUpdateOrganizationRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Success */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["SocializeApiModulesOrganizationsHandlersOrganizationDto"];
|
||||
};
|
||||
};
|
||||
/** @description Bad Request */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["FastEndpointsErrorResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
SocializeApiModulesOrganizationsHandlersGetOrganizationsHandler: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -3602,6 +3785,144 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
SocializeApiModulesCalendarIntegrationsHandlersListCalendarSourcesHandler: {
|
||||
parameters: {
|
||||
query?: {
|
||||
workspaceId?: string | null;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Success */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["SocializeApiModulesCalendarIntegrationsHandlersCalendarSourceDto"][];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
SocializeApiModulesCalendarIntegrationsHandlersCreateCalendarSourceHandler: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["SocializeApiModulesCalendarIntegrationsHandlersUpsertCalendarSourceRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Success */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["SocializeApiModulesCalendarIntegrationsHandlersCalendarSourceDto"];
|
||||
};
|
||||
};
|
||||
/** @description Bad Request */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["FastEndpointsErrorResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
SocializeApiModulesCalendarIntegrationsHandlersUpdateCalendarSourceHandler: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
sourceId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["SocializeApiModulesCalendarIntegrationsHandlersUpsertCalendarSourceRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Success */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["SocializeApiModulesCalendarIntegrationsHandlersCalendarSourceDto"];
|
||||
};
|
||||
};
|
||||
/** @description Bad Request */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["FastEndpointsErrorResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
SocializeApiModulesCalendarIntegrationsHandlersDeleteCalendarSourceHandler: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
sourceId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description No Content */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
SocializeApiModulesAssetsHandlersCreateAssetRevisionHandler: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
import { useAuthStore } from '@/features/auth/stores/authStore.js';
|
||||
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
|
||||
import { useClient } from '@/plugins/api.js';
|
||||
|
||||
export const useCalendarIntegrationsStore = defineStore('calendar-integrations', () => {
|
||||
const authStore = useAuthStore();
|
||||
const workspaceStore = useWorkspaceStore();
|
||||
const client = useClient();
|
||||
|
||||
const sources = ref([]);
|
||||
const events = ref([]);
|
||||
const catalogEntries = ref([]);
|
||||
const hiddenSourceIds = ref(new Set());
|
||||
const isLoadingSources = ref(false);
|
||||
const isLoadingEvents = ref(false);
|
||||
const isLoadingCatalog = ref(false);
|
||||
const isCreatingSource = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
const visibleSourceIds = computed(() =>
|
||||
new Set(sources.value
|
||||
.filter(source => source.isEnabled && !hiddenSourceIds.value.has(source.id))
|
||||
.map(source => source.id))
|
||||
);
|
||||
|
||||
const visibleEvents = computed(() =>
|
||||
events.value.filter(event => visibleSourceIds.value.has(event.calendarSourceId))
|
||||
);
|
||||
|
||||
function sourceById(sourceId) {
|
||||
return sources.value.find(source => source.id === sourceId) ?? null;
|
||||
}
|
||||
|
||||
async function fetchSources(workspaceId = workspaceStore.activeWorkspaceId) {
|
||||
if (!authStore.isAuthenticated) {
|
||||
sources.value = [];
|
||||
error.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
isLoadingSources.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const response = await client.get('/api/calendar-integrations/sources', {
|
||||
params: {
|
||||
workspaceId: workspaceId ?? undefined,
|
||||
},
|
||||
});
|
||||
sources.value = response.data ?? [];
|
||||
} catch (fetchError) {
|
||||
console.error('Failed to fetch calendar sources:', fetchError);
|
||||
sources.value = [];
|
||||
error.value = 'Failed to load calendar sources.';
|
||||
} finally {
|
||||
isLoadingSources.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchEvents({ workspaceId = workspaceStore.activeWorkspaceId, startDate, endDate } = {}) {
|
||||
if (!authStore.isAuthenticated) {
|
||||
events.value = [];
|
||||
error.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
isLoadingEvents.value = true;
|
||||
|
||||
try {
|
||||
const response = await client.get('/api/calendar-integrations/events', {
|
||||
params: {
|
||||
workspaceId: workspaceId ?? undefined,
|
||||
startDate,
|
||||
endDate,
|
||||
},
|
||||
});
|
||||
events.value = response.data ?? [];
|
||||
} catch (fetchError) {
|
||||
console.error('Failed to fetch calendar events:', fetchError);
|
||||
events.value = [];
|
||||
error.value = 'Failed to load calendar events.';
|
||||
} finally {
|
||||
isLoadingEvents.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function searchCatalog(filters = {}) {
|
||||
if (!authStore.isAuthenticated) {
|
||||
catalogEntries.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
isLoadingCatalog.value = true;
|
||||
|
||||
try {
|
||||
const response = await client.get('/api/calendar-integrations/catalog', {
|
||||
params: filters,
|
||||
});
|
||||
catalogEntries.value = response.data ?? [];
|
||||
} catch (fetchError) {
|
||||
console.error('Failed to search calendar catalog:', fetchError);
|
||||
catalogEntries.value = [];
|
||||
error.value = 'Failed to load calendar catalog.';
|
||||
} finally {
|
||||
isLoadingCatalog.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createSource(payload) {
|
||||
isCreatingSource.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const response = await client.post('/api/calendar-integrations/sources', payload);
|
||||
if (response.data) {
|
||||
sources.value = [...sources.value, response.data]
|
||||
.sort((left, right) => left.displayTitle.localeCompare(right.displayTitle));
|
||||
}
|
||||
return response.data;
|
||||
} catch (createError) {
|
||||
console.error('Failed to create calendar source:', createError);
|
||||
error.value = 'Failed to add calendar source.';
|
||||
throw createError;
|
||||
} finally {
|
||||
isCreatingSource.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshSource(sourceId) {
|
||||
if (!sourceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await client.post(`/api/calendar-integrations/sources/${sourceId}/refresh`);
|
||||
const refreshedSource = response.data;
|
||||
if (refreshedSource) {
|
||||
sources.value = sources.value.map(source =>
|
||||
source.id === refreshedSource.id ? refreshedSource : source
|
||||
);
|
||||
}
|
||||
return refreshedSource;
|
||||
} catch (refreshError) {
|
||||
console.error('Failed to refresh calendar source:', refreshError);
|
||||
error.value = 'Failed to refresh calendar source.';
|
||||
throw refreshError;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSourceVisibility(sourceId) {
|
||||
const nextHiddenIds = new Set(hiddenSourceIds.value);
|
||||
if (nextHiddenIds.has(sourceId)) {
|
||||
nextHiddenIds.delete(sourceId);
|
||||
} else {
|
||||
nextHiddenIds.add(sourceId);
|
||||
}
|
||||
hiddenSourceIds.value = nextHiddenIds;
|
||||
}
|
||||
|
||||
return {
|
||||
sources,
|
||||
events,
|
||||
catalogEntries,
|
||||
hiddenSourceIds,
|
||||
visibleSourceIds,
|
||||
visibleEvents,
|
||||
isLoadingSources,
|
||||
isLoadingEvents,
|
||||
isLoadingCatalog,
|
||||
isCreatingSource,
|
||||
error,
|
||||
sourceById,
|
||||
fetchSources,
|
||||
fetchEvents,
|
||||
searchCatalog,
|
||||
createSource,
|
||||
refreshSource,
|
||||
toggleSourceVisibility,
|
||||
};
|
||||
});
|
||||
@@ -13,6 +13,7 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () =
|
||||
const comments = ref([]);
|
||||
const approvals = ref([]);
|
||||
const notifications = ref([]);
|
||||
const activity = ref([]);
|
||||
const isLoading = ref(false);
|
||||
const error = ref(null);
|
||||
const actions = reactive({
|
||||
@@ -35,6 +36,7 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () =
|
||||
comments.value = [];
|
||||
approvals.value = [];
|
||||
notifications.value = [];
|
||||
activity.value = [];
|
||||
error.value = null;
|
||||
}
|
||||
|
||||
@@ -49,19 +51,14 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () =
|
||||
assetsResponse,
|
||||
commentsResponse,
|
||||
approvalsResponse,
|
||||
notificationsResponse,
|
||||
activityResponse,
|
||||
] = await Promise.all([
|
||||
client.get(`/api/content-items/${contentItemId}`),
|
||||
client.get(`/api/content-items/${contentItemId}/revisions`),
|
||||
client.get('/api/assets', { params: { contentItemId } }),
|
||||
client.get('/api/comments', { params: { contentItemId } }),
|
||||
client.get('/api/approvals', { params: { contentItemId } }),
|
||||
client.get('/api/notifications', {
|
||||
params: {
|
||||
workspaceId: workspaceStore.activeWorkspaceId ?? undefined,
|
||||
contentItemId,
|
||||
},
|
||||
}),
|
||||
client.get(`/api/content-items/${contentItemId}/activity`),
|
||||
]);
|
||||
|
||||
item.value = itemResponse.data;
|
||||
@@ -69,7 +66,7 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () =
|
||||
assets.value = assetsResponse.data ?? [];
|
||||
comments.value = commentsResponse.data ?? [];
|
||||
approvals.value = approvalsResponse.data ?? [];
|
||||
notifications.value = notificationsResponse.data ?? [];
|
||||
activity.value = activityResponse.data ?? [];
|
||||
} catch (fetchError) {
|
||||
console.error('Failed to load content item detail:', fetchError);
|
||||
reset();
|
||||
@@ -105,7 +102,7 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () =
|
||||
});
|
||||
if (response.data) {
|
||||
assets.value = [...assets.value, response.data];
|
||||
await fetchNotifications(contentItemId);
|
||||
await fetchActivity(contentItemId);
|
||||
}
|
||||
return response.data;
|
||||
} finally {
|
||||
@@ -120,7 +117,7 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () =
|
||||
const response = await client.post(`/api/assets/${assetId}/revisions`, payload);
|
||||
if (response.data) {
|
||||
await fetchAssets(contentItemId);
|
||||
await fetchNotifications(contentItemId);
|
||||
await fetchActivity(contentItemId);
|
||||
}
|
||||
return response.data;
|
||||
} finally {
|
||||
@@ -139,22 +136,7 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () =
|
||||
});
|
||||
if (response.data) {
|
||||
comments.value = [...comments.value, response.data];
|
||||
await fetchNotifications(contentItemId);
|
||||
}
|
||||
return response.data;
|
||||
} finally {
|
||||
actions.comment = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveComment(contentItemId, commentId) {
|
||||
actions.comment = true;
|
||||
|
||||
try {
|
||||
const response = await client.post(`/api/comments/${commentId}/resolve`);
|
||||
if (response.data) {
|
||||
comments.value = comments.value.map(comment => comment.id === commentId ? response.data : comment);
|
||||
await fetchNotifications(contentItemId);
|
||||
await fetchActivity(contentItemId);
|
||||
}
|
||||
return response.data;
|
||||
} finally {
|
||||
@@ -170,7 +152,7 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () =
|
||||
if (response.data) {
|
||||
approvals.value = approvals.value.map(approval => approval.id === approvalId ? response.data : approval);
|
||||
await fetchContentItem(contentItemId);
|
||||
await fetchNotifications(contentItemId);
|
||||
await fetchActivity(contentItemId);
|
||||
}
|
||||
return response.data;
|
||||
} finally {
|
||||
@@ -184,7 +166,7 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () =
|
||||
try {
|
||||
const response = await client.post(`/api/content-items/${contentItemId}/status`, { status });
|
||||
item.value = response.data;
|
||||
await fetchNotifications(contentItemId);
|
||||
await fetchActivity(contentItemId);
|
||||
return response.data;
|
||||
} finally {
|
||||
actions.status = false;
|
||||
@@ -214,6 +196,12 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () =
|
||||
return notifications.value;
|
||||
}
|
||||
|
||||
async function fetchActivity(contentItemId) {
|
||||
const response = await client.get(`/api/content-items/${contentItemId}/activity`);
|
||||
activity.value = response.data ?? [];
|
||||
return activity.value;
|
||||
}
|
||||
|
||||
return {
|
||||
item,
|
||||
revisions,
|
||||
@@ -221,6 +209,7 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () =
|
||||
comments,
|
||||
approvals,
|
||||
notifications,
|
||||
activity,
|
||||
isLoading,
|
||||
error,
|
||||
actions,
|
||||
@@ -230,8 +219,8 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () =
|
||||
addGoogleDriveAsset,
|
||||
addAssetRevision,
|
||||
addComment,
|
||||
resolveComment,
|
||||
submitDecision,
|
||||
updateStatus,
|
||||
fetchActivity,
|
||||
};
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,20 +1,44 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { mdiChevronLeft, mdiChevronRight } from '@mdi/js';
|
||||
import { mdiCalendarPlus, mdiChevronDown, mdiChevronLeft, mdiChevronRight, mdiClose, mdiMagnify, mdiPlus } from '@mdi/js';
|
||||
import { useCampaignsStore } from '@/features/campaigns/stores/campaignsStore.js';
|
||||
import { organizationPermissions, useOrganizationStore } from '@/features/organizations/stores/organizationStore.js';
|
||||
import { useAuthStore } from '@/features/auth/stores/authStore.js';
|
||||
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
|
||||
import { useCalendarIntegrationsStore } from '@/features/content/stores/calendarIntegrationsStore.js';
|
||||
import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js';
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const workspaceStore = useWorkspaceStore();
|
||||
const organizationStore = useOrganizationStore();
|
||||
const campaignsStore = useCampaignsStore();
|
||||
const contentItemsStore = useContentItemsStore();
|
||||
const calendarStore = useCalendarIntegrationsStore();
|
||||
|
||||
const today = startOfDay(new Date());
|
||||
const viewMode = ref(parseViewMode(route.query.view));
|
||||
const cursorDate = ref(parseCursorDate(route.query.date, today));
|
||||
const isAddCalendarOpen = ref(route.query.addCalendar === 'true');
|
||||
const isCalendarSelectorOpen = ref(false);
|
||||
const activeAddMode = ref('catalog');
|
||||
const catalogFilters = reactive({
|
||||
search: '',
|
||||
country: '',
|
||||
category: '',
|
||||
});
|
||||
const customCalendarForm = reactive({
|
||||
title: '',
|
||||
sourceUrl: '',
|
||||
color: '#2F80ED',
|
||||
category: 'public-holiday',
|
||||
scope: 'User',
|
||||
});
|
||||
const addCalendarError = ref('');
|
||||
|
||||
const contentStatusMeta = {
|
||||
Draft: { tone: 'production' },
|
||||
@@ -46,7 +70,10 @@
|
||||
.filter(item => item.dueDate)
|
||||
.map(item => buildContentEntry(item));
|
||||
|
||||
return [...campaignEntries, ...contentEntries].sort(sortByDate);
|
||||
const importedEntries = calendarStore.visibleEvents
|
||||
.map(event => buildImportedCalendarEntry(event));
|
||||
|
||||
return [...campaignEntries, ...contentEntries, ...importedEntries].sort(sortByDate);
|
||||
});
|
||||
|
||||
const entriesByDay = computed(() => {
|
||||
@@ -133,6 +160,12 @@
|
||||
})
|
||||
);
|
||||
|
||||
const upcomingEntries = computed(() =>
|
||||
calendarEntries.value
|
||||
.filter(entry => entry.scheduledAt >= today)
|
||||
.slice(0, 80)
|
||||
);
|
||||
|
||||
const isLoading = computed(() =>
|
||||
contentItemsStore.isLoading || campaignsStore.isLoading
|
||||
);
|
||||
@@ -142,6 +175,44 @@
|
||||
);
|
||||
|
||||
const isCalendarView = computed(() => viewMode.value === 'month' || viewMode.value === 'week');
|
||||
const calendarWorkspaceId = computed(() =>
|
||||
workspaceStore.activeWorkspaceId ?? workspaceStore.visibleWorkspaceIds[0] ?? null
|
||||
);
|
||||
const calendarRange = computed(() => {
|
||||
if (viewMode.value === 'week') {
|
||||
const start = startOfWeek(cursorDate.value);
|
||||
return {
|
||||
startDate: dateKey(start),
|
||||
endDate: dateKey(addDays(start, 6)),
|
||||
};
|
||||
}
|
||||
|
||||
const start = startOfWeek(startOfMonth(cursorDate.value));
|
||||
const end = endOfWeek(endOfMonth(cursorDate.value));
|
||||
return {
|
||||
startDate: dateKey(start),
|
||||
endDate: dateKey(end),
|
||||
};
|
||||
});
|
||||
const canManageOrganizationCalendars = computed(() =>
|
||||
organizationStore.userCan(organizationStore.activeOrganization, organizationPermissions.manageConnectors)
|
||||
);
|
||||
const canManageWorkspaceCalendars = computed(() => authStore.isManager);
|
||||
const availableCalendarSources = computed(() =>
|
||||
[...calendarStore.sources].sort((left, right) => {
|
||||
const scopeSort = ['Organization', 'Workspace', 'User'];
|
||||
const scopeDiff = scopeSort.indexOf(left.scope) - scopeSort.indexOf(right.scope);
|
||||
return scopeDiff || left.displayTitle.localeCompare(right.displayTitle);
|
||||
})
|
||||
);
|
||||
const visibleCalendarSourceCount = computed(() =>
|
||||
availableCalendarSources.value.filter(source => sourceIsVisible(source.id)).length
|
||||
);
|
||||
const addScopeOptions = computed(() => [
|
||||
...(canManageOrganizationCalendars.value ? [{ value: 'Organization', label: t('contentItems.calendar.organization') }] : []),
|
||||
...(canManageWorkspaceCalendars.value && calendarWorkspaceId.value ? [{ value: 'Workspace', label: t('contentItems.calendar.workspace') }] : []),
|
||||
{ value: 'User', label: t('contentItems.calendar.mine') },
|
||||
]);
|
||||
|
||||
function buildDay(date, isOutsideMonth) {
|
||||
const key = dateKey(date);
|
||||
@@ -191,6 +262,205 @@
|
||||
};
|
||||
}
|
||||
|
||||
function buildImportedCalendarEntry(event) {
|
||||
const source = calendarStore.sourceById(event.calendarSourceId);
|
||||
const scheduledAt = parseCalendarEventDate(event);
|
||||
|
||||
return {
|
||||
id: event.id,
|
||||
type: 'imported-calendar',
|
||||
title: event.title,
|
||||
subtitle: source?.displayTitle ?? t('contentItems.calendar.importedEvent'),
|
||||
scheduledAt,
|
||||
dayKey: dateKey(event.startDate),
|
||||
timeLabel: event.isAllDay ? t('contentItems.calendar.allDay') : formatHour(scheduledAt),
|
||||
tone: 'calendar-context',
|
||||
source,
|
||||
color: source?.color ?? '#64748b',
|
||||
route: null,
|
||||
};
|
||||
}
|
||||
|
||||
function parseCalendarEventDate(event) {
|
||||
if (event.startLocalDateTime) {
|
||||
return new Date(event.startLocalDateTime);
|
||||
}
|
||||
|
||||
if (event.startUtc) {
|
||||
return new Date(event.startUtc);
|
||||
}
|
||||
|
||||
return new Date(`${event.startDate}T00:00:00`);
|
||||
}
|
||||
|
||||
function normalizeCalendarUrl(value) {
|
||||
return String(value ?? '').trim().replace(/\/$/, '').toLowerCase();
|
||||
}
|
||||
|
||||
function entryStyle(entry) {
|
||||
if (entry.type !== 'imported-calendar') {
|
||||
return {};
|
||||
}
|
||||
|
||||
const color = entry.color || '#64748b';
|
||||
return {
|
||||
borderColor: `${color}55`,
|
||||
borderLeftColor: color,
|
||||
};
|
||||
}
|
||||
|
||||
function sourceIsVisible(sourceId) {
|
||||
return !calendarStore.hiddenSourceIds.has(sourceId);
|
||||
}
|
||||
|
||||
function toggleSource(sourceId) {
|
||||
calendarStore.toggleSourceVisibility(sourceId);
|
||||
}
|
||||
|
||||
function openAddCalendar() {
|
||||
isCalendarSelectorOpen.value = false;
|
||||
isAddCalendarOpen.value = true;
|
||||
}
|
||||
|
||||
function createFromImportedEvent(entry) {
|
||||
router.push({
|
||||
name: 'content-item-create',
|
||||
query: {
|
||||
date: entry.dayKey,
|
||||
title: entry.title,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshCalendarData() {
|
||||
if (!calendarWorkspaceId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
await calendarStore.fetchSources(calendarWorkspaceId.value);
|
||||
|
||||
const sourcesToRefresh = calendarStore.sources.filter(source =>
|
||||
source.isEnabled &&
|
||||
!calendarStore.hiddenSourceIds.has(source.id) &&
|
||||
(!source.lastSuccessfulSyncAt || source.lastSyncError)
|
||||
);
|
||||
|
||||
await Promise.all(sourcesToRefresh.map(source => calendarStore.refreshSource(source.id)));
|
||||
|
||||
await calendarStore.fetchEvents({
|
||||
workspaceId: calendarWorkspaceId.value,
|
||||
startDate: calendarRange.value.startDate,
|
||||
endDate: calendarRange.value.endDate,
|
||||
});
|
||||
}
|
||||
|
||||
async function searchCatalog() {
|
||||
await calendarStore.searchCatalog({
|
||||
search: catalogFilters.search || undefined,
|
||||
country: catalogFilters.country || undefined,
|
||||
category: catalogFilters.category || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async function addCatalogSource(entry) {
|
||||
if (catalogEntryAlreadyAdded(entry)) {
|
||||
addCalendarError.value = t('contentItems.calendar.errors.duplicate');
|
||||
return;
|
||||
}
|
||||
|
||||
await addCalendarSource({
|
||||
title: entry.title,
|
||||
sourceUrl: entry.sourceUrl,
|
||||
color: entry.defaultColor,
|
||||
category: entry.category,
|
||||
catalogSourceReference: String(entry.id),
|
||||
});
|
||||
}
|
||||
|
||||
async function addCustomSource() {
|
||||
await addCalendarSource({
|
||||
title: customCalendarForm.title,
|
||||
sourceUrl: customCalendarForm.sourceUrl,
|
||||
color: customCalendarForm.color,
|
||||
category: customCalendarForm.category,
|
||||
catalogSourceReference: null,
|
||||
});
|
||||
}
|
||||
|
||||
async function addCalendarSource({ title, sourceUrl, color, category, catalogSourceReference }) {
|
||||
addCalendarError.value = '';
|
||||
const scope = customCalendarForm.scope;
|
||||
const payload = {
|
||||
scope,
|
||||
organizationId: scope === 'Organization' ? organizationStore.activeOrganization?.id : null,
|
||||
workspaceId: scope === 'Workspace' ? calendarWorkspaceId.value : null,
|
||||
sourceUrl,
|
||||
catalogSourceReference,
|
||||
displayTitle: title.trim(),
|
||||
color,
|
||||
category,
|
||||
isEnabled: true,
|
||||
inheritanceMode: scope === 'Organization' ? 'Optional' : null,
|
||||
};
|
||||
|
||||
if (!payload.displayTitle || !payload.sourceUrl) {
|
||||
addCalendarError.value = t('contentItems.calendar.errors.required');
|
||||
return;
|
||||
}
|
||||
|
||||
if (sourceAlreadyAdded(payload)) {
|
||||
addCalendarError.value = t('contentItems.calendar.errors.duplicate');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const source = await calendarStore.createSource(payload);
|
||||
await calendarStore.refreshSource(source?.id);
|
||||
isAddCalendarOpen.value = false;
|
||||
customCalendarForm.title = '';
|
||||
customCalendarForm.sourceUrl = '';
|
||||
await refreshCalendarData();
|
||||
} catch {
|
||||
addCalendarError.value = t('contentItems.calendar.errors.createFailed');
|
||||
}
|
||||
}
|
||||
|
||||
function catalogEntryAlreadyAdded(entry) {
|
||||
return sourceAlreadyAdded({
|
||||
scope: customCalendarForm.scope,
|
||||
organizationId: customCalendarForm.scope === 'Organization' ? organizationStore.activeOrganization?.id : null,
|
||||
workspaceId: customCalendarForm.scope === 'Workspace' ? calendarWorkspaceId.value : null,
|
||||
sourceUrl: entry.sourceUrl,
|
||||
catalogSourceReference: String(entry.id),
|
||||
});
|
||||
}
|
||||
|
||||
function sourceAlreadyAdded(payload) {
|
||||
const normalizedUrl = normalizeCalendarUrl(payload.sourceUrl);
|
||||
const normalizedReference = String(payload.catalogSourceReference ?? '').trim();
|
||||
|
||||
return calendarStore.sources.some(source => {
|
||||
if (source.scope !== payload.scope) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (payload.scope === 'Organization' && source.organizationId !== payload.organizationId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (payload.scope === 'Workspace' && source.workspaceId !== payload.workspaceId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (payload.scope === 'User' && (source.organizationId || source.workspaceId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Boolean(normalizedReference && source.catalogSourceReference === normalizedReference) ||
|
||||
Boolean(normalizedUrl && normalizeCalendarUrl(source.sourceUrl) === normalizedUrl);
|
||||
});
|
||||
}
|
||||
|
||||
function setView(mode) {
|
||||
viewMode.value = mode;
|
||||
|
||||
@@ -228,6 +498,14 @@
|
||||
return value ? new Date(value).toLocaleDateString() : t('contentItems.noDueDate');
|
||||
}
|
||||
|
||||
function formatEntryDate(value) {
|
||||
return new Intl.DateTimeFormat(locale.value, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function startOfDay(value) {
|
||||
const date = new Date(value);
|
||||
date.setHours(0, 0, 0, 0);
|
||||
@@ -271,6 +549,10 @@
|
||||
}
|
||||
|
||||
function dateKey(value) {
|
||||
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}/.test(value)) {
|
||||
return value.slice(0, 10);
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
@@ -317,6 +599,33 @@
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => [calendarWorkspaceId.value, viewMode.value, calendarRange.value.startDate, calendarRange.value.endDate],
|
||||
async () => {
|
||||
await refreshCalendarData();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => addScopeOptions.value.map(option => option.value).join(','),
|
||||
() => {
|
||||
if (!addScopeOptions.value.some(option => option.value === customCalendarForm.scope)) {
|
||||
customCalendarForm.scope = addScopeOptions.value[0]?.value ?? 'User';
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => isAddCalendarOpen.value,
|
||||
async value => {
|
||||
if (value && calendarStore.catalogEntries.length === 0) {
|
||||
await searchCatalog();
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -326,31 +635,84 @@
|
||||
<h1>{{ t('contentItems.title') }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="view-toggle">
|
||||
<button
|
||||
class="toggle-button"
|
||||
:class="{ 'toggle-button-active': viewMode === 'month' }"
|
||||
type="button"
|
||||
@click="setView('month')"
|
||||
>
|
||||
{{ t('dashboard.month') }}
|
||||
</button>
|
||||
<button
|
||||
class="toggle-button"
|
||||
:class="{ 'toggle-button-active': viewMode === 'week' }"
|
||||
type="button"
|
||||
@click="setView('week')"
|
||||
>
|
||||
{{ t('dashboard.week') }}
|
||||
</button>
|
||||
<button
|
||||
class="toggle-button"
|
||||
:class="{ 'toggle-button-active': viewMode === 'upcoming' }"
|
||||
type="button"
|
||||
@click="setView('upcoming')"
|
||||
>
|
||||
{{ t('contentItems.upcoming') }}
|
||||
</button>
|
||||
<div class="header-actions">
|
||||
<div class="calendar-selector">
|
||||
<button
|
||||
class="calendar-selector-button"
|
||||
type="button"
|
||||
@click="isCalendarSelectorOpen = !isCalendarSelectorOpen"
|
||||
>
|
||||
<span>{{ t('contentItems.calendar.calendars') }}</span>
|
||||
<strong>{{ visibleCalendarSourceCount }}/{{ availableCalendarSources.length }}</strong>
|
||||
<v-icon :icon="mdiChevronDown" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="isCalendarSelectorOpen"
|
||||
class="calendar-selector-menu"
|
||||
>
|
||||
<button
|
||||
v-for="source in availableCalendarSources"
|
||||
:key="source.id"
|
||||
class="calendar-selector-row"
|
||||
type="button"
|
||||
@click="toggleSource(source.id)"
|
||||
>
|
||||
<span
|
||||
class="source-swatch"
|
||||
:style="{ background: source.color }"
|
||||
/>
|
||||
<span class="calendar-selector-title">{{ source.displayTitle }}</span>
|
||||
<span
|
||||
class="visibility-switch"
|
||||
:class="{ active: sourceIsVisible(source.id) }"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="!availableCalendarSources.length"
|
||||
class="calendar-selector-empty"
|
||||
>
|
||||
{{ t('contentItems.calendar.noCalendars') }}
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="calendar-selector-add"
|
||||
type="button"
|
||||
@click="openAddCalendar"
|
||||
>
|
||||
<v-icon :icon="mdiCalendarPlus" />
|
||||
<span>{{ t('contentItems.calendar.addCalendar') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="view-toggle">
|
||||
<button
|
||||
class="toggle-button"
|
||||
:class="{ 'toggle-button-active': viewMode === 'month' }"
|
||||
type="button"
|
||||
@click="setView('month')"
|
||||
>
|
||||
{{ t('dashboard.month') }}
|
||||
</button>
|
||||
<button
|
||||
class="toggle-button"
|
||||
:class="{ 'toggle-button-active': viewMode === 'week' }"
|
||||
type="button"
|
||||
@click="setView('week')"
|
||||
>
|
||||
{{ t('dashboard.week') }}
|
||||
</button>
|
||||
<button
|
||||
class="toggle-button"
|
||||
:class="{ 'toggle-button-active': viewMode === 'upcoming' }"
|
||||
type="button"
|
||||
@click="setView('upcoming')"
|
||||
>
|
||||
{{ t('contentItems.upcoming') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -434,17 +796,34 @@
|
||||
v-if="day.entries.length"
|
||||
class="day-entries"
|
||||
>
|
||||
<router-link
|
||||
<template
|
||||
v-for="entry in viewMode === 'month' ? day.entries.slice(0, 3) : day.entries"
|
||||
:key="`${entry.type}-${entry.id}`"
|
||||
:to="entry.route"
|
||||
class="calendar-entry"
|
||||
:class="entry.tone"
|
||||
>
|
||||
<span class="entry-time">{{ entry.timeLabel }}</span>
|
||||
<strong>{{ entry.title }}</strong>
|
||||
<span>{{ entry.subtitle }}</span>
|
||||
</router-link>
|
||||
<button
|
||||
v-if="entry.type === 'imported-calendar'"
|
||||
class="calendar-entry calendar-context-entry"
|
||||
:class="entry.tone"
|
||||
:style="entryStyle(entry)"
|
||||
type="button"
|
||||
@click="createFromImportedEvent(entry)"
|
||||
>
|
||||
<span class="entry-time">{{ entry.timeLabel }}</span>
|
||||
<strong>{{ entry.title }}</strong>
|
||||
<span>{{ entry.subtitle }}</span>
|
||||
</button>
|
||||
|
||||
<router-link
|
||||
v-else
|
||||
:to="entry.route"
|
||||
class="calendar-entry"
|
||||
:class="entry.tone"
|
||||
>
|
||||
<span class="entry-time">{{ entry.timeLabel }}</span>
|
||||
<strong>{{ entry.title }}</strong>
|
||||
<span>{{ entry.subtitle }}</span>
|
||||
</router-link>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="viewMode === 'month' && day.entries.length > 3"
|
||||
@@ -465,23 +844,57 @@
|
||||
</article>
|
||||
|
||||
<div
|
||||
v-else-if="upcomingItems.length"
|
||||
v-else-if="upcomingEntries.length"
|
||||
class="item-grid"
|
||||
>
|
||||
<router-link
|
||||
v-for="item in upcomingItems"
|
||||
:key="item.id"
|
||||
:to="{ name: 'content-item-detail', params: { id: item.id }, query: { returnTo: route.fullPath } }"
|
||||
class="item-card"
|
||||
<template
|
||||
v-for="entry in upcomingEntries"
|
||||
:key="`${entry.type}-${entry.id}`"
|
||||
>
|
||||
<div class="version-chip">{{ item.currentRevisionLabel }}</div>
|
||||
<strong>{{ item.title }}</strong>
|
||||
<span>{{ item.publicationTargets }}</span>
|
||||
<div class="status-row">
|
||||
<em>{{ item.status }}</em>
|
||||
<small>{{ formatDueDate(item.dueDate) }}</small>
|
||||
</div>
|
||||
</router-link>
|
||||
<button
|
||||
v-if="entry.type === 'imported-calendar'"
|
||||
class="item-card calendar-upcoming-card"
|
||||
:style="entryStyle(entry)"
|
||||
type="button"
|
||||
@click="createFromImportedEvent(entry)"
|
||||
>
|
||||
<div class="version-chip">{{ t('contentItems.calendar.context') }}</div>
|
||||
<strong>{{ entry.title }}</strong>
|
||||
<span>{{ entry.subtitle }}</span>
|
||||
<div class="status-row">
|
||||
<em>{{ entry.timeLabel }}</em>
|
||||
<small>{{ formatEntryDate(entry.scheduledAt) }}</small>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<router-link
|
||||
v-else-if="entry.type === 'content'"
|
||||
:to="entry.route"
|
||||
class="item-card"
|
||||
>
|
||||
<div class="version-chip">{{ contentItemsStore.items.find(item => item.id === entry.id)?.currentRevisionLabel }}</div>
|
||||
<strong>{{ entry.title }}</strong>
|
||||
<span>{{ contentItemsStore.items.find(item => item.id === entry.id)?.publicationTargets }}</span>
|
||||
<div class="status-row">
|
||||
<em>{{ contentItemsStore.items.find(item => item.id === entry.id)?.status }}</em>
|
||||
<small>{{ formatEntryDate(entry.scheduledAt) }}</small>
|
||||
</div>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
v-else
|
||||
:to="entry.route"
|
||||
class="item-card"
|
||||
>
|
||||
<div class="version-chip">{{ t('dashboard.campaignDeadline') }}</div>
|
||||
<strong>{{ entry.title }}</strong>
|
||||
<span>{{ entry.subtitle }}</span>
|
||||
<div class="status-row">
|
||||
<em>{{ entry.timeLabel }}</em>
|
||||
<small>{{ formatEntryDate(entry.scheduledAt) }}</small>
|
||||
</div>
|
||||
</router-link>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -490,6 +903,155 @@
|
||||
>
|
||||
{{ t('contentItems.empty') }}
|
||||
</div>
|
||||
|
||||
<v-dialog
|
||||
v-model="isAddCalendarOpen"
|
||||
max-width="760"
|
||||
>
|
||||
<div class="calendar-dialog">
|
||||
<div class="dialog-header">
|
||||
<strong>{{ t('contentItems.calendar.addCalendar') }}</strong>
|
||||
<button
|
||||
class="icon-button"
|
||||
type="button"
|
||||
@click="isAddCalendarOpen = false"
|
||||
>
|
||||
<v-icon :icon="mdiClose" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="add-mode-toggle">
|
||||
<button
|
||||
class="toggle-button"
|
||||
:class="{ 'toggle-button-active': activeAddMode === 'catalog' }"
|
||||
type="button"
|
||||
@click="activeAddMode = 'catalog'"
|
||||
>
|
||||
{{ t('contentItems.calendar.catalog') }}
|
||||
</button>
|
||||
<button
|
||||
class="toggle-button"
|
||||
:class="{ 'toggle-button-active': activeAddMode === 'custom' }"
|
||||
type="button"
|
||||
@click="activeAddMode = 'custom'"
|
||||
>
|
||||
{{ t('contentItems.calendar.customIcs') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="scope-row">
|
||||
<label
|
||||
v-for="option in addScopeOptions"
|
||||
:key="option.value"
|
||||
class="scope-option"
|
||||
>
|
||||
<input
|
||||
v-model="customCalendarForm.scope"
|
||||
type="radio"
|
||||
:value="option.value"
|
||||
>
|
||||
<span>{{ option.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="activeAddMode === 'catalog'"
|
||||
class="catalog-panel"
|
||||
>
|
||||
<div class="catalog-search">
|
||||
<input
|
||||
v-model="catalogFilters.search"
|
||||
type="search"
|
||||
:placeholder="t('contentItems.calendar.searchCatalog')"
|
||||
>
|
||||
<input
|
||||
v-model="catalogFilters.country"
|
||||
type="text"
|
||||
maxlength="2"
|
||||
:placeholder="t('contentItems.calendar.country')"
|
||||
>
|
||||
<input
|
||||
v-model="catalogFilters.category"
|
||||
type="text"
|
||||
:placeholder="t('contentItems.calendar.category')"
|
||||
>
|
||||
<button
|
||||
class="text-button"
|
||||
type="button"
|
||||
@click="searchCatalog"
|
||||
>
|
||||
<v-icon :icon="mdiMagnify" />
|
||||
<span>{{ t('contentItems.calendar.search') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="catalog-results">
|
||||
<button
|
||||
v-for="entry in calendarStore.catalogEntries"
|
||||
:key="entry.id"
|
||||
class="catalog-entry"
|
||||
:class="{ 'catalog-entry-disabled': catalogEntryAlreadyAdded(entry) }"
|
||||
type="button"
|
||||
:disabled="catalogEntryAlreadyAdded(entry)"
|
||||
@click="addCatalogSource(entry)"
|
||||
>
|
||||
<span
|
||||
class="source-swatch"
|
||||
:style="{ background: entry.defaultColor }"
|
||||
/>
|
||||
<strong>{{ entry.title }}</strong>
|
||||
<span>
|
||||
{{ catalogEntryAlreadyAdded(entry)
|
||||
? t('contentItems.calendar.alreadyAdded')
|
||||
: `${entry.providerName} · ${entry.category}` }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
v-else
|
||||
class="custom-calendar-form"
|
||||
@submit.prevent="addCustomSource"
|
||||
>
|
||||
<input
|
||||
v-model="customCalendarForm.title"
|
||||
type="text"
|
||||
:placeholder="t('contentItems.calendar.calendarName')"
|
||||
>
|
||||
<input
|
||||
v-model="customCalendarForm.sourceUrl"
|
||||
type="url"
|
||||
:placeholder="t('contentItems.calendar.icsUrl')"
|
||||
>
|
||||
<div class="custom-form-row">
|
||||
<input
|
||||
v-model="customCalendarForm.color"
|
||||
type="color"
|
||||
>
|
||||
<input
|
||||
v-model="customCalendarForm.category"
|
||||
type="text"
|
||||
:placeholder="t('contentItems.calendar.category')"
|
||||
>
|
||||
<button
|
||||
class="text-button"
|
||||
type="submit"
|
||||
>
|
||||
<v-icon :icon="mdiPlus" />
|
||||
<span>{{ t('contentItems.calendar.addCalendar') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p
|
||||
v-if="addCalendarError || calendarStore.error"
|
||||
class="dialog-error"
|
||||
>
|
||||
{{ addCalendarError || calendarStore.error }}
|
||||
</p>
|
||||
</div>
|
||||
</v-dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -514,12 +1076,84 @@
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
@apply flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-end;
|
||||
}
|
||||
|
||||
.view-toggle {
|
||||
@apply inline-flex w-fit rounded-full border p-1;
|
||||
background: #f8fafc;
|
||||
border-color: rgba(23, 32, 51, 0.1);
|
||||
}
|
||||
|
||||
.calendar-selector {
|
||||
@apply relative w-full sm:w-auto;
|
||||
}
|
||||
|
||||
.calendar-selector-button {
|
||||
@apply inline-flex min-h-11 w-full items-center justify-between gap-2 rounded-full border px-4 py-2 text-sm font-bold transition sm:w-auto;
|
||||
background: #ffffff;
|
||||
border-color: rgba(23, 32, 51, 0.1);
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.calendar-selector-button strong {
|
||||
@apply rounded-full px-2 py-0.5 text-xs;
|
||||
background: rgba(15, 118, 110, 0.1);
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.calendar-selector-menu {
|
||||
@apply absolute right-0 top-[calc(100%+0.5rem)] z-30 flex w-full min-w-72 flex-col gap-1 rounded-[1rem] border p-2 shadow-xl sm:w-80;
|
||||
background: #ffffff;
|
||||
border-color: rgba(23, 32, 51, 0.1);
|
||||
}
|
||||
|
||||
.calendar-selector-row,
|
||||
.calendar-selector-add {
|
||||
@apply flex min-h-11 w-full items-center gap-3 rounded-[0.75rem] px-3 text-left text-sm font-semibold transition;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.calendar-selector-row:hover,
|
||||
.calendar-selector-add:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.calendar-selector-title {
|
||||
@apply min-w-0 flex-1 truncate;
|
||||
}
|
||||
|
||||
.calendar-selector-empty {
|
||||
@apply px-3 py-2 text-sm;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.calendar-selector-add {
|
||||
@apply border-t;
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.visibility-switch {
|
||||
@apply relative h-6 w-10 shrink-0 rounded-full transition;
|
||||
background: rgba(148, 163, 184, 0.35);
|
||||
}
|
||||
|
||||
.visibility-switch::after {
|
||||
@apply absolute left-1 top-1 h-4 w-4 rounded-full bg-white transition;
|
||||
content: '';
|
||||
box-shadow: 0 1px 4px rgba(23, 32, 51, 0.2);
|
||||
}
|
||||
|
||||
.visibility-switch.active {
|
||||
background: #0f766e;
|
||||
}
|
||||
|
||||
.visibility-switch.active::after {
|
||||
transform: translateX(1rem);
|
||||
}
|
||||
|
||||
.toggle-button,
|
||||
.icon-button,
|
||||
.text-button {
|
||||
@@ -584,6 +1218,10 @@
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.source-swatch {
|
||||
@apply h-3 w-3 shrink-0 rounded-full;
|
||||
}
|
||||
|
||||
.calendar-grid {
|
||||
@apply grid gap-3;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
@@ -631,6 +1269,11 @@
|
||||
@apply flex flex-col gap-0.5 rounded-[1rem] border px-3 py-2 no-underline transition;
|
||||
}
|
||||
|
||||
button.calendar-entry,
|
||||
button.item-card {
|
||||
@apply w-full text-left;
|
||||
}
|
||||
|
||||
.calendar-entry:hover,
|
||||
.item-card:hover {
|
||||
transform: translateY(-1px);
|
||||
@@ -690,6 +1333,105 @@
|
||||
border-color: rgba(148, 163, 184, 0.18);
|
||||
}
|
||||
|
||||
.calendar-context-entry {
|
||||
border-left-width: 4px;
|
||||
background: #ffffff;
|
||||
opacity: 0.86;
|
||||
}
|
||||
|
||||
.calendar-context-entry strong {
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.calendar-upcoming-card {
|
||||
border-left-width: 4px;
|
||||
}
|
||||
|
||||
.calendar-dialog {
|
||||
@apply flex flex-col gap-4 rounded-[1.5rem] border bg-white p-5;
|
||||
border-color: rgba(23, 32, 51, 0.1);
|
||||
}
|
||||
|
||||
.dialog-header,
|
||||
.add-mode-toggle,
|
||||
.scope-row,
|
||||
.catalog-search,
|
||||
.custom-form-row {
|
||||
@apply flex flex-wrap items-center gap-3;
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
@apply justify-between;
|
||||
}
|
||||
|
||||
.dialog-header strong {
|
||||
@apply text-lg font-black;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.scope-option {
|
||||
@apply inline-flex items-center gap-2 rounded-full border px-3 py-2 text-sm font-semibold;
|
||||
border-color: rgba(23, 32, 51, 0.1);
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.catalog-panel,
|
||||
.custom-calendar-form,
|
||||
.catalog-results {
|
||||
@apply flex flex-col gap-3;
|
||||
}
|
||||
|
||||
.catalog-search input,
|
||||
.custom-calendar-form input {
|
||||
@apply min-h-11 rounded-[0.75rem] border px-3 text-sm;
|
||||
border-color: rgba(23, 32, 51, 0.12);
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.catalog-search input[type='search'],
|
||||
.custom-calendar-form input[type='url'],
|
||||
.custom-calendar-form input[type='text'] {
|
||||
@apply min-w-0 flex-1;
|
||||
}
|
||||
|
||||
.catalog-results {
|
||||
@apply max-h-[22rem] overflow-auto;
|
||||
}
|
||||
|
||||
.catalog-entry {
|
||||
@apply grid min-h-14 grid-cols-[auto_minmax(0,1fr)] items-center gap-x-3 rounded-[0.75rem] border px-3 py-2 text-left transition;
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.catalog-entry:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.catalog-entry-disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.catalog-entry-disabled:hover {
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.catalog-entry strong {
|
||||
@apply text-sm font-bold;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.catalog-entry span:last-child {
|
||||
@apply col-start-2 text-xs;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.dialog-error {
|
||||
@apply text-sm font-semibold;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.item-grid {
|
||||
@apply grid gap-4 md:grid-cols-2 xl:grid-cols-3;
|
||||
}
|
||||
|
||||
@@ -22,11 +22,19 @@ export const useOrganizationStore = defineStore('organization', () => {
|
||||
const detailsById = ref({});
|
||||
const isLoading = ref(false);
|
||||
const isLoadingDetails = ref(false);
|
||||
const isSaving = ref(false);
|
||||
const isAddingMember = ref(false);
|
||||
const isUploadingLogo = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
const activeOrganization = computed(() =>
|
||||
organizations.value.find(organization => organization.id === selectedOrganizationId.value) ?? null
|
||||
);
|
||||
const activeOrganization = computed(() => {
|
||||
const organization = organizations.value.find(candidate => candidate.id === selectedOrganizationId.value) ?? null;
|
||||
const details = selectedOrganizationId.value ? detailsById.value[selectedOrganizationId.value] : null;
|
||||
|
||||
return organization || details
|
||||
? { ...(organization ?? {}), ...(details ?? {}) }
|
||||
: null;
|
||||
});
|
||||
|
||||
function userCan(organization, permission) {
|
||||
return Boolean(organization?.currentUserPermissions?.includes(permission));
|
||||
@@ -109,6 +117,129 @@ export const useOrganizationStore = defineStore('organization', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function updateOrganization(organizationId, payload) {
|
||||
if (!authStore.isAuthenticated || !organizationId) {
|
||||
throw new Error('You must be authenticated to update an organization.');
|
||||
}
|
||||
|
||||
isSaving.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const response = await client.put(`/api/organizations/${organizationId}`, payload);
|
||||
const organization = response.data;
|
||||
|
||||
if (organization) {
|
||||
const currentDetails = detailsById.value[organizationId];
|
||||
detailsById.value = {
|
||||
...detailsById.value,
|
||||
[organizationId]: {
|
||||
...(currentDetails ?? {}),
|
||||
...organization,
|
||||
members: currentDetails?.members ?? organization.members ?? [],
|
||||
workspaces: currentDetails?.workspaces ?? organization.workspaces ?? [],
|
||||
},
|
||||
};
|
||||
organizations.value = organizations.value.map(candidate =>
|
||||
candidate.id === organizationId
|
||||
? { ...candidate, ...organization }
|
||||
: candidate
|
||||
);
|
||||
}
|
||||
|
||||
return organization;
|
||||
} catch (updateError) {
|
||||
console.error('Failed to update organization:', updateError);
|
||||
error.value = 'Failed to update organization.';
|
||||
throw updateError;
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addMember(organizationId, payload) {
|
||||
if (!authStore.isAuthenticated || !organizationId) {
|
||||
throw new Error('You must be authenticated to add an organization member.');
|
||||
}
|
||||
|
||||
isAddingMember.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const response = await client.post(`/api/organizations/${organizationId}/members`, payload);
|
||||
const member = response.data;
|
||||
|
||||
if (member) {
|
||||
const currentDetails = detailsById.value[organizationId];
|
||||
if (currentDetails) {
|
||||
detailsById.value = {
|
||||
...detailsById.value,
|
||||
[organizationId]: {
|
||||
...currentDetails,
|
||||
members: [...(currentDetails.members ?? []), member],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return member;
|
||||
} catch (addError) {
|
||||
console.error('Failed to add organization member:', addError);
|
||||
error.value = 'Failed to add organization member.';
|
||||
throw addError;
|
||||
} finally {
|
||||
isAddingMember.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadLogo(organizationId, file) {
|
||||
if (!authStore.isAuthenticated || !organizationId) {
|
||||
throw new Error('You must be authenticated to upload an organization logo.');
|
||||
}
|
||||
|
||||
if (isUploadingLogo.value) {
|
||||
throw new Error('An organization logo upload is already in progress.');
|
||||
}
|
||||
|
||||
isUploadingLogo.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file, file.name || 'organization-logo.png');
|
||||
|
||||
const response = await client.post(`/api/organizations/${organizationId}/logo`, formData);
|
||||
const blobUrl = response.data?.blobUrl;
|
||||
|
||||
if (blobUrl) {
|
||||
const logoUrl = `${blobUrl}?${Date.now()}`;
|
||||
const currentDetails = detailsById.value[organizationId];
|
||||
if (currentDetails) {
|
||||
detailsById.value = {
|
||||
...detailsById.value,
|
||||
[organizationId]: {
|
||||
...currentDetails,
|
||||
logoUrl,
|
||||
},
|
||||
};
|
||||
}
|
||||
organizations.value = organizations.value.map(organization =>
|
||||
organization.id === organizationId
|
||||
? { ...organization, logoUrl }
|
||||
: organization
|
||||
);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
} catch (uploadError) {
|
||||
console.error('Failed to upload organization logo:', uploadError);
|
||||
error.value = 'Failed to upload organization logo.';
|
||||
throw uploadError;
|
||||
} finally {
|
||||
isUploadingLogo.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => authStore.isAuthenticated,
|
||||
async isAuthenticated => {
|
||||
@@ -132,11 +263,17 @@ export const useOrganizationStore = defineStore('organization', () => {
|
||||
detailsById,
|
||||
isLoading,
|
||||
isLoadingDetails,
|
||||
isSaving,
|
||||
isAddingMember,
|
||||
isUploadingLogo,
|
||||
error,
|
||||
userCan,
|
||||
setSelectedOrganization,
|
||||
setSelectedOrganizationFromWorkspace,
|
||||
fetchOrganizations,
|
||||
fetchOrganization,
|
||||
updateOrganization,
|
||||
addMember,
|
||||
uploadLogo,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,26 +1,41 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import AppAvatar from '@/components/AppAvatar.vue';
|
||||
import ImageCropperDialog from '@/components/ImageCropperDialog.vue';
|
||||
import {
|
||||
mdiAccountGroupOutline,
|
||||
mdiBriefcaseOutline,
|
||||
mdiCogOutline,
|
||||
mdiChartBar,
|
||||
mdiCheck,
|
||||
mdiClose,
|
||||
mdiCreditCardOutline,
|
||||
mdiLanConnect,
|
||||
mdiPencilOutline,
|
||||
} from '@mdi/js';
|
||||
import {
|
||||
organizationPermissions,
|
||||
useOrganizationStore,
|
||||
} from '@/features/organizations/stores/organizationStore.js';
|
||||
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const organizationStore = useOrganizationStore();
|
||||
const workspaceStore = useWorkspaceStore();
|
||||
const activeSectionKey = ref('profile');
|
||||
const activeSectionKey = ref('members');
|
||||
const settingsError = ref(null);
|
||||
const settingsStatus = ref(null);
|
||||
const logoError = ref(null);
|
||||
const logoStatus = ref(null);
|
||||
const isLogoDialogOpen = ref(false);
|
||||
const isEditingName = ref(false);
|
||||
const profileForm = reactive({
|
||||
name: '',
|
||||
});
|
||||
const memberForm = reactive({
|
||||
email: '',
|
||||
role: 'Member',
|
||||
});
|
||||
const memberRoleOptions = ['Member', 'Admin', 'BillingManager', 'ConnectorManager'];
|
||||
|
||||
const organizationId = computed(() => route.params.organizationId);
|
||||
const organization = computed(() =>
|
||||
@@ -32,22 +47,26 @@
|
||||
const canViewMembers = computed(() =>
|
||||
permissions.value.includes(organizationPermissions.manageOrganizationMembers)
|
||||
);
|
||||
const canManageSettings = computed(() =>
|
||||
permissions.value.includes(organizationPermissions.manageOrganizationSettings)
|
||||
);
|
||||
const canViewBilling = computed(() =>
|
||||
permissions.value.includes(organizationPermissions.manageBilling)
|
||||
);
|
||||
const canViewConnections = computed(() =>
|
||||
permissions.value.includes(organizationPermissions.manageConnectors)
|
||||
);
|
||||
const canViewWorkspaces = computed(() =>
|
||||
const canViewUsage = computed(() =>
|
||||
permissions.value.includes(organizationPermissions.manageWorkspaces) ||
|
||||
permissions.value.includes(organizationPermissions.manageBilling) ||
|
||||
permissions.value.includes(organizationPermissions.createWorkspaces)
|
||||
);
|
||||
const usageItems = computed(() => organization.value?.usage?.items ?? []);
|
||||
const visibleSections = computed(() => [
|
||||
{ key: 'profile', icon: mdiCogOutline, visible: true },
|
||||
{ key: 'members', icon: mdiAccountGroupOutline, visible: canViewMembers.value },
|
||||
{ key: 'usage', icon: mdiChartBar, visible: canViewUsage.value },
|
||||
{ key: 'billing', icon: mdiCreditCardOutline, visible: canViewBilling.value },
|
||||
{ key: 'connections', icon: mdiLanConnect, visible: canViewConnections.value },
|
||||
{ key: 'workspaces', icon: mdiBriefcaseOutline, visible: canViewWorkspaces.value },
|
||||
].filter(section => section.visible));
|
||||
const activeSection = computed(() =>
|
||||
visibleSections.value.find(section => section.key === activeSectionKey.value) ??
|
||||
@@ -55,10 +74,6 @@
|
||||
null
|
||||
);
|
||||
|
||||
function hasPermission(permission) {
|
||||
return permissions.value.includes(permission);
|
||||
}
|
||||
|
||||
async function loadOrganization() {
|
||||
if (!organizationId.value) {
|
||||
return;
|
||||
@@ -67,22 +82,103 @@
|
||||
await organizationStore.fetchOrganization(organizationId.value);
|
||||
}
|
||||
|
||||
async function openWorkspace(workspaceId) {
|
||||
const workspace = organization.value?.workspaces?.find(candidate => candidate.id === workspaceId);
|
||||
if (workspace) {
|
||||
workspaceStore.setActiveWorkspace(workspace.id);
|
||||
await router.push({ name: 'workspace-dashboard' });
|
||||
async function submitProfile() {
|
||||
settingsError.value = null;
|
||||
settingsStatus.value = null;
|
||||
|
||||
const name = profileForm.name.trim();
|
||||
if (!name) {
|
||||
settingsError.value = t('organizationSettings.errors.nameRequired');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await organizationStore.updateOrganization(organizationId.value, { name });
|
||||
settingsStatus.value = t('organizationSettings.profileSaved');
|
||||
isEditingName.value = false;
|
||||
} catch (error) {
|
||||
console.error('Failed to save organization profile:', error);
|
||||
settingsError.value = t('organizationSettings.errors.profileSaveFailed');
|
||||
}
|
||||
}
|
||||
|
||||
function startEditingName() {
|
||||
profileForm.name = organization.value?.name ?? '';
|
||||
settingsError.value = null;
|
||||
settingsStatus.value = null;
|
||||
isEditingName.value = true;
|
||||
}
|
||||
|
||||
function cancelEditingName() {
|
||||
profileForm.name = organization.value?.name ?? '';
|
||||
settingsError.value = null;
|
||||
isEditingName.value = false;
|
||||
}
|
||||
|
||||
async function saveOrganizationLogo(result) {
|
||||
if (!organization.value || organizationStore.isUploadingLogo) {
|
||||
return;
|
||||
}
|
||||
|
||||
logoError.value = null;
|
||||
logoStatus.value = null;
|
||||
|
||||
try {
|
||||
await organizationStore.uploadLogo(organizationId.value, result.file);
|
||||
logoStatus.value = t('organizationSettings.logo.saved');
|
||||
isLogoDialogOpen.value = false;
|
||||
} catch (error) {
|
||||
console.error('Failed to update organization logo:', error);
|
||||
logoError.value = t('organizationSettings.errors.logoUploadFailed');
|
||||
}
|
||||
}
|
||||
|
||||
async function submitMember() {
|
||||
settingsError.value = null;
|
||||
settingsStatus.value = null;
|
||||
|
||||
if (!memberForm.email.trim() || !memberForm.role) {
|
||||
settingsError.value = t('organizationSettings.errors.memberRequired');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await organizationStore.addMember(organizationId.value, {
|
||||
email: memberForm.email.trim(),
|
||||
role: memberForm.role,
|
||||
});
|
||||
memberForm.email = '';
|
||||
memberForm.role = 'Member';
|
||||
settingsStatus.value = t('organizationSettings.memberAdded');
|
||||
} catch (error) {
|
||||
console.error('Failed to add organization member:', error);
|
||||
settingsError.value = t('organizationSettings.errors.memberAddFailed');
|
||||
}
|
||||
}
|
||||
|
||||
function usagePercent(item) {
|
||||
if (!item.limit) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.min(100, Math.round((item.used / item.limit) * 100));
|
||||
}
|
||||
|
||||
onMounted(loadOrganization);
|
||||
|
||||
watch(organizationId, loadOrganization);
|
||||
watch(
|
||||
organization,
|
||||
currentOrganization => {
|
||||
profileForm.name = currentOrganization?.name ?? '';
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
watch(
|
||||
visibleSections,
|
||||
sections => {
|
||||
if (!sections.some(section => section.key === activeSectionKey.value)) {
|
||||
activeSectionKey.value = sections[0]?.key ?? 'profile';
|
||||
activeSectionKey.value = sections[0]?.key ?? null;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
@@ -92,10 +188,88 @@
|
||||
<template>
|
||||
<section class="organization-settings-shell">
|
||||
<div class="settings-hero">
|
||||
<div>
|
||||
<div class="settings-hero-copy">
|
||||
<div class="eyebrow">{{ t('organizationSettings.eyebrow') }}</div>
|
||||
<h1>{{ organization?.name ?? t('organizationSettings.title') }}</h1>
|
||||
<p>{{ t('organizationSettings.description') }}</p>
|
||||
<div class="organization-title-line">
|
||||
<button
|
||||
v-if="organization"
|
||||
class="organization-logo-button"
|
||||
type="button"
|
||||
:disabled="!canManageSettings || organizationStore.isUploadingLogo"
|
||||
:aria-label="t('organizationSettings.logo.changeAction')"
|
||||
:title="t('organizationSettings.logo.changeAction')"
|
||||
@click="isLogoDialogOpen = true"
|
||||
>
|
||||
<AppAvatar
|
||||
:name="profileForm.name || organization.name"
|
||||
:src="organization.logoUrl"
|
||||
size="lg"
|
||||
/>
|
||||
</button>
|
||||
<form
|
||||
v-if="organization && isEditingName"
|
||||
class="title-edit-form"
|
||||
@submit.prevent="submitProfile"
|
||||
>
|
||||
<input
|
||||
v-model="profileForm.name"
|
||||
type="text"
|
||||
maxlength="256"
|
||||
autocomplete="organization"
|
||||
:aria-label="t('organizationSettings.fields.name')"
|
||||
>
|
||||
<button
|
||||
class="icon-action"
|
||||
type="submit"
|
||||
:disabled="organizationStore.isSaving"
|
||||
:aria-label="t('organizationSettings.saveName')"
|
||||
:title="t('organizationSettings.saveName')"
|
||||
>
|
||||
<v-icon :icon="mdiCheck" />
|
||||
</button>
|
||||
<button
|
||||
class="icon-action secondary"
|
||||
type="button"
|
||||
:disabled="organizationStore.isSaving"
|
||||
:aria-label="t('organizationSettings.cancelNameEdit')"
|
||||
:title="t('organizationSettings.cancelNameEdit')"
|
||||
@click="cancelEditingName"
|
||||
>
|
||||
<v-icon :icon="mdiClose" />
|
||||
</button>
|
||||
</form>
|
||||
<div
|
||||
v-else
|
||||
class="title-row"
|
||||
>
|
||||
<h1>{{ organization?.name ?? t('organizationSettings.title') }}</h1>
|
||||
<button
|
||||
v-if="organization && canManageSettings"
|
||||
class="icon-action secondary"
|
||||
type="button"
|
||||
:aria-label="t('organizationSettings.editName')"
|
||||
:title="t('organizationSettings.editName')"
|
||||
@click="startEditingName"
|
||||
>
|
||||
<v-icon :icon="mdiPencilOutline" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-status">
|
||||
<small
|
||||
v-if="logoError"
|
||||
class="field-error"
|
||||
>
|
||||
{{ logoError }}
|
||||
</small>
|
||||
<small
|
||||
v-if="logoStatus"
|
||||
class="field-success"
|
||||
>
|
||||
{{ logoStatus }}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -145,35 +319,57 @@
|
||||
|
||||
<article class="content-card">
|
||||
<div
|
||||
v-if="activeSection.key === 'profile'"
|
||||
class="detail-list"
|
||||
v-if="settingsError"
|
||||
class="settings-alert error"
|
||||
>
|
||||
<div>
|
||||
<span>{{ t('organizationSettings.fields.name') }}</span>
|
||||
<strong>{{ organization.name }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>{{ t('organizationSettings.fields.createdAt') }}</span>
|
||||
<strong>{{ new Date(organization.createdAt).toLocaleDateString() }}</strong>
|
||||
</div>
|
||||
<div class="permissions-panel">
|
||||
<span>{{ t('organizationSettings.permissions.title') }}</span>
|
||||
<div class="permission-grid">
|
||||
<span
|
||||
v-for="permission in Object.values(organizationPermissions)"
|
||||
:key="permission"
|
||||
:class="{ enabled: hasPermission(permission) }"
|
||||
>
|
||||
{{ t(`organizationSettings.permissions.items.${permission}`) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{{ settingsError }}
|
||||
</div>
|
||||
<div
|
||||
v-if="settingsStatus"
|
||||
class="settings-alert success"
|
||||
>
|
||||
{{ settingsStatus }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="activeSection.key === 'members'"
|
||||
v-if="activeSection.key === 'members'"
|
||||
class="table-list"
|
||||
>
|
||||
<form
|
||||
class="settings-form invite-form"
|
||||
@submit.prevent="submitMember"
|
||||
>
|
||||
<label>
|
||||
<span>{{ t('organizationSettings.fields.memberEmail') }}</span>
|
||||
<input
|
||||
v-model="memberForm.email"
|
||||
type="email"
|
||||
maxlength="256"
|
||||
autocomplete="email"
|
||||
>
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ t('organizationSettings.fields.memberRole') }}</span>
|
||||
<select v-model="memberForm.role">
|
||||
<option
|
||||
v-for="role in memberRoleOptions"
|
||||
:key="role"
|
||||
:value="role"
|
||||
>
|
||||
{{ t(`organizationSettings.roles.${role}`, role) }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button
|
||||
class="primary-action"
|
||||
type="submit"
|
||||
:disabled="organizationStore.isAddingMember"
|
||||
>
|
||||
{{ organizationStore.isAddingMember ? t('organizationSettings.addingMember') : t('organizationSettings.addMember') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div
|
||||
v-for="member in organization.members"
|
||||
:key="member.userId"
|
||||
@@ -210,31 +406,48 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="activeSection.key === 'workspaces'"
|
||||
class="table-list"
|
||||
v-else-if="activeSection.key === 'usage'"
|
||||
class="usage-list"
|
||||
>
|
||||
<button
|
||||
v-for="workspace in organization.workspaces"
|
||||
:key="workspace.id"
|
||||
class="table-row table-row-button"
|
||||
type="button"
|
||||
@click="openWorkspace(workspace.id)"
|
||||
>
|
||||
<div>
|
||||
<strong>{{ workspace.name }}</strong>
|
||||
<span>{{ workspace.timeZone }}</span>
|
||||
</div>
|
||||
</button>
|
||||
<div class="usage-plan">
|
||||
<strong>{{ t('organizationSettings.sections.usage.planLabel') }}</strong>
|
||||
<span>{{ organization.usage?.planName ?? t('organizationSettings.sections.usage.planFallback') }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="!organization.workspaces?.length"
|
||||
v-for="item in usageItems"
|
||||
:key="item.key"
|
||||
class="usage-row"
|
||||
>
|
||||
<div class="usage-row-heading">
|
||||
<strong>{{ t(`organizationSettings.usage.items.${item.key}`) }}</strong>
|
||||
<span>
|
||||
{{ item.used }} / {{ item.limit ?? t('organizationSettings.usage.unlimited') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="usage-meter">
|
||||
<span :style="{ width: `${usagePercent(item)}%` }" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="!usageItems.length"
|
||||
class="empty-state"
|
||||
>
|
||||
{{ t('organizationSettings.sections.workspaces.empty') }}
|
||||
{{ t('organizationSettings.sections.usage.empty') }}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ImageCropperDialog
|
||||
v-model="isLogoDialogOpen"
|
||||
:title="t('organizationSettings.logo.cropperTitle')"
|
||||
:confirm-label="t('organizationSettings.logo.saveAction')"
|
||||
:upload-label="t('organizationSettings.logo.chooseAction')"
|
||||
:initial-url="organization?.logoUrl"
|
||||
:is-saving="organizationStore.isUploadingLogo"
|
||||
@save="saveOrganizationLogo"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -243,19 +456,35 @@
|
||||
@apply mx-auto flex w-full max-w-6xl flex-col gap-6 px-5 py-8 md:px-8;
|
||||
}
|
||||
|
||||
.settings-hero {
|
||||
@apply flex flex-col;
|
||||
}
|
||||
|
||||
.settings-hero-copy {
|
||||
@apply flex min-w-0 flex-1 flex-col gap-1;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
@apply text-xs font-bold uppercase tracking-[0.2em];
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.settings-hero h1 {
|
||||
@apply mt-3 text-3xl font-black md:text-4xl;
|
||||
.title-row {
|
||||
@apply flex min-w-0 items-center gap-2;
|
||||
}
|
||||
|
||||
.title-row h1 {
|
||||
@apply min-w-0 break-words;
|
||||
}
|
||||
|
||||
.settings-hero h1,
|
||||
.title-edit-form input {
|
||||
@apply min-w-0 text-3xl font-black md:text-4xl;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.settings-hero p,
|
||||
.section-heading p,
|
||||
.detail-list span,
|
||||
.table-row span,
|
||||
.placeholder-panel span,
|
||||
.empty-state {
|
||||
@@ -263,6 +492,62 @@
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.organization-title-line {
|
||||
@apply mt-5 flex min-w-0 items-center gap-3;
|
||||
}
|
||||
|
||||
.organization-logo-button {
|
||||
@apply inline-flex size-14 flex-shrink-0 items-center justify-center rounded-[0.75rem] border bg-white transition-colors md:size-16;
|
||||
border-color: rgba(23, 32, 51, 0.12);
|
||||
}
|
||||
|
||||
.organization-logo-button:hover:not(:disabled) {
|
||||
border-color: #0f766e;
|
||||
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.12);
|
||||
}
|
||||
|
||||
.organization-logo-button:disabled {
|
||||
@apply cursor-default opacity-80;
|
||||
}
|
||||
|
||||
.title-edit-form {
|
||||
@apply flex min-w-0 flex-1 flex-wrap items-center gap-2;
|
||||
}
|
||||
|
||||
.title-edit-form input {
|
||||
@apply h-12 min-w-0 flex-1 rounded-[0.5rem] border bg-white px-3 outline-none transition-colors md:h-14;
|
||||
border-color: rgba(23, 32, 51, 0.14);
|
||||
}
|
||||
|
||||
.title-edit-form input:focus {
|
||||
border-color: #0f766e;
|
||||
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.12);
|
||||
}
|
||||
|
||||
.icon-action {
|
||||
@apply inline-flex size-9 flex-shrink-0 items-center justify-center rounded-[0.5rem] transition-colors;
|
||||
background: #172033;
|
||||
color: #fffaf2;
|
||||
}
|
||||
|
||||
.icon-action.secondary {
|
||||
background: rgba(23, 32, 51, 0.06);
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.icon-action:hover:not(:disabled) {
|
||||
background: #0f766e;
|
||||
color: #fffaf2;
|
||||
}
|
||||
|
||||
.icon-action:disabled {
|
||||
@apply cursor-not-allowed opacity-60;
|
||||
}
|
||||
|
||||
.hero-status {
|
||||
@apply flex min-h-5 flex-col;
|
||||
}
|
||||
|
||||
.settings-page {
|
||||
@apply flex flex-col gap-5;
|
||||
}
|
||||
@@ -305,17 +590,15 @@
|
||||
}
|
||||
|
||||
.content-card {
|
||||
@apply rounded-[0.75rem] border p-5;
|
||||
@apply flex flex-col gap-4 rounded-[0.75rem] border p-5;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
.detail-list,
|
||||
.table-list {
|
||||
@apply flex flex-col gap-2;
|
||||
}
|
||||
|
||||
.detail-list div,
|
||||
.table-row {
|
||||
@apply flex items-center justify-between gap-4 rounded-[0.75rem] px-4 py-3;
|
||||
background: rgba(23, 32, 51, 0.04);
|
||||
@@ -337,7 +620,6 @@
|
||||
@apply flex min-w-0 flex-col;
|
||||
}
|
||||
|
||||
.detail-list strong,
|
||||
.table-row strong,
|
||||
.placeholder-panel strong {
|
||||
@apply font-semibold;
|
||||
@@ -349,33 +631,120 @@
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.permissions-panel,
|
||||
.placeholder-panel,
|
||||
.empty-state {
|
||||
@apply rounded-[0.75rem] px-4 py-4;
|
||||
background: rgba(23, 32, 51, 0.04);
|
||||
}
|
||||
|
||||
.permissions-panel {
|
||||
@apply flex-col items-start gap-3;
|
||||
.settings-form {
|
||||
@apply flex flex-col gap-4 rounded-[0.75rem] p-4;
|
||||
background: rgba(23, 32, 51, 0.04);
|
||||
}
|
||||
|
||||
.usage-plan span,
|
||||
.usage-row-heading span {
|
||||
@apply text-sm;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.field-error {
|
||||
color: #991b1b !important;
|
||||
}
|
||||
|
||||
.field-success {
|
||||
color: #0f766e !important;
|
||||
}
|
||||
|
||||
.invite-form {
|
||||
@apply md:grid md:grid-cols-[minmax(0,1fr)_14rem_auto] md:items-end;
|
||||
}
|
||||
|
||||
.settings-form label {
|
||||
@apply flex min-w-0 flex-col gap-2 text-sm font-semibold;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.settings-form input,
|
||||
.settings-form select {
|
||||
@apply h-11 w-full rounded-[0.5rem] border px-3 text-sm outline-none transition-colors;
|
||||
background: #ffffff;
|
||||
border-color: rgba(23, 32, 51, 0.14);
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.settings-form input:focus,
|
||||
.settings-form select:focus {
|
||||
border-color: #0f766e;
|
||||
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.12);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
@apply flex justify-end;
|
||||
}
|
||||
|
||||
.primary-action {
|
||||
@apply inline-flex h-11 items-center justify-center rounded-[0.5rem] px-4 text-sm font-bold transition-colors;
|
||||
background: #172033;
|
||||
color: #fffaf2;
|
||||
}
|
||||
|
||||
.primary-action:hover:not(:disabled) {
|
||||
background: #0f766e;
|
||||
}
|
||||
|
||||
.primary-action:disabled {
|
||||
@apply cursor-not-allowed opacity-60;
|
||||
}
|
||||
|
||||
.settings-alert {
|
||||
@apply rounded-[0.5rem] px-4 py-3 text-sm font-semibold;
|
||||
}
|
||||
|
||||
.settings-alert.error {
|
||||
background: rgba(185, 28, 28, 0.1);
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.settings-alert.success {
|
||||
background: rgba(15, 118, 110, 0.12);
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.placeholder-panel {
|
||||
@apply flex flex-col gap-1;
|
||||
}
|
||||
|
||||
.permission-grid {
|
||||
@apply flex flex-wrap gap-2;
|
||||
.usage-list {
|
||||
@apply flex flex-col gap-3;
|
||||
}
|
||||
|
||||
.permission-grid span {
|
||||
@apply rounded-full px-3 py-2 text-xs font-bold;
|
||||
background: rgba(23, 32, 51, 0.06);
|
||||
color: #526178;
|
||||
.usage-plan,
|
||||
.usage-row {
|
||||
@apply rounded-[0.75rem] p-4;
|
||||
background: rgba(23, 32, 51, 0.04);
|
||||
}
|
||||
|
||||
.permission-grid span.enabled {
|
||||
background: rgba(15, 118, 110, 0.12);
|
||||
color: #0f766e;
|
||||
.usage-plan {
|
||||
@apply flex items-center justify-between gap-4;
|
||||
}
|
||||
|
||||
.usage-row {
|
||||
@apply flex flex-col gap-3;
|
||||
}
|
||||
|
||||
.usage-row-heading {
|
||||
@apply flex items-center justify-between gap-4;
|
||||
}
|
||||
|
||||
.usage-meter {
|
||||
@apply h-2 overflow-hidden rounded-full;
|
||||
background: rgba(23, 32, 51, 0.1);
|
||||
}
|
||||
|
||||
.usage-meter span {
|
||||
@apply block h-full rounded-full;
|
||||
background: #0f766e;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -11,6 +11,9 @@ export const useUserProfileStore = defineStore(
|
||||
const authStore = useAuthStore()
|
||||
const isUpdating = ref(false)
|
||||
const isUploadingPortrait = ref(false)
|
||||
const isLoadingCalendarFeed = ref(false)
|
||||
const isUpdatingCalendarFeed = ref(false)
|
||||
const calendarExportFeed = ref(null)
|
||||
const error = ref(null)
|
||||
|
||||
const authWatcher = watch(
|
||||
@@ -18,8 +21,10 @@ export const useUserProfileStore = defineStore(
|
||||
async (newValue) => {
|
||||
if (newValue) {
|
||||
await fetchCurrentUserProfile()
|
||||
await fetchCalendarExportFeed()
|
||||
} else if (!authStore.isRefreshing) {
|
||||
value.value = undefined
|
||||
calendarExportFeed.value = null
|
||||
}
|
||||
})
|
||||
|
||||
@@ -202,6 +207,56 @@ export const useUserProfileStore = defineStore(
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCalendarExportFeed() {
|
||||
if (!authStore.isAuthenticated) {
|
||||
calendarExportFeed.value = null
|
||||
return
|
||||
}
|
||||
|
||||
isLoadingCalendarFeed.value = true
|
||||
|
||||
try {
|
||||
const client = useClient()
|
||||
const response = await client.get('/api/calendar-integrations/export-feed')
|
||||
calendarExportFeed.value = response.data
|
||||
} catch (fetchError) {
|
||||
console.error(fetchError)
|
||||
error.value = 'Failed to load calendar export feed.'
|
||||
} finally {
|
||||
isLoadingCalendarFeed.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function enableCalendarExportFeed() {
|
||||
return updateCalendarExportFeed('/api/calendar-integrations/export-feed/enable', 'post')
|
||||
}
|
||||
|
||||
async function regenerateCalendarExportFeed() {
|
||||
return updateCalendarExportFeed('/api/calendar-integrations/export-feed/regenerate', 'post')
|
||||
}
|
||||
|
||||
async function revokeCalendarExportFeed() {
|
||||
return updateCalendarExportFeed('/api/calendar-integrations/export-feed', 'delete')
|
||||
}
|
||||
|
||||
async function updateCalendarExportFeed(url, method) {
|
||||
isUpdatingCalendarFeed.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const client = useClient()
|
||||
const response = await client[method](url)
|
||||
calendarExportFeed.value = response.data
|
||||
return response.data
|
||||
} catch (updateError) {
|
||||
console.error(updateError)
|
||||
error.value = 'Failed to update calendar export feed.'
|
||||
throw updateError
|
||||
} finally {
|
||||
isUpdatingCalendarFeed.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
user: value,
|
||||
alias,
|
||||
@@ -209,6 +264,9 @@ export const useUserProfileStore = defineStore(
|
||||
portraitUrl,
|
||||
isUpdating,
|
||||
isUploadingPortrait,
|
||||
isLoadingCalendarFeed,
|
||||
isUpdatingCalendarFeed,
|
||||
calendarExportFeed,
|
||||
error,
|
||||
roles,
|
||||
persona,
|
||||
@@ -221,6 +279,10 @@ export const useUserProfileStore = defineStore(
|
||||
changePhone,
|
||||
changeEmail,
|
||||
changeAddress,
|
||||
changePortrait
|
||||
changePortrait,
|
||||
fetchCalendarExportFeed,
|
||||
enableCalendarExportFeed,
|
||||
regenerateCalendarExportFeed,
|
||||
revokeCalendarExportFeed
|
||||
}
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import AppAvatar from '@/components/AppAvatar.vue';
|
||||
import ImageCropperDialog from '@/components/ImageCropperDialog.vue';
|
||||
import { useUserProfileStore } from '@/features/user-profile/stores/userProfileStore.js';
|
||||
import config from '@/config.js';
|
||||
|
||||
const userProfileStore = useUserProfileStore();
|
||||
const { t } = useI18n();
|
||||
@@ -11,6 +12,8 @@
|
||||
const isSavingPortrait = ref(false);
|
||||
const settingsError = ref(null);
|
||||
const settingsStatus = ref(null);
|
||||
const calendarFeedStatus = ref(null);
|
||||
const calendarFeedError = ref(null);
|
||||
const form = reactive({
|
||||
firstname: '',
|
||||
lastname: '',
|
||||
@@ -22,6 +25,17 @@
|
||||
const alias = computed(() => userProfileStore.alias);
|
||||
const fullname = computed(() => userProfileStore.fullname);
|
||||
const canSave = computed(() => Boolean(form.email.trim()) && !userProfileStore.isUpdating);
|
||||
const calendarFeedUrl = computed(() => {
|
||||
const feedUrl = userProfileStore.calendarExportFeed?.feedUrl;
|
||||
|
||||
if (!feedUrl) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return feedUrl.startsWith('http')
|
||||
? feedUrl
|
||||
: `${config.apiUrl.replace(/\/$/, '')}${feedUrl}`;
|
||||
});
|
||||
|
||||
function syncFormFromUser(user) {
|
||||
form.firstname = user?.firstname ?? '';
|
||||
@@ -84,11 +98,54 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function enableCalendarFeed() {
|
||||
await updateCalendarFeed(() => userProfileStore.enableCalendarExportFeed(), t('userSettings.calendarFeed.enabled'));
|
||||
}
|
||||
|
||||
async function regenerateCalendarFeed() {
|
||||
await updateCalendarFeed(() => userProfileStore.regenerateCalendarExportFeed(), t('userSettings.calendarFeed.regenerated'));
|
||||
}
|
||||
|
||||
async function revokeCalendarFeed() {
|
||||
await updateCalendarFeed(() => userProfileStore.revokeCalendarExportFeed(), t('userSettings.calendarFeed.revoked'));
|
||||
}
|
||||
|
||||
async function copyCalendarFeedUrl() {
|
||||
if (!calendarFeedUrl.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(calendarFeedUrl.value);
|
||||
calendarFeedStatus.value = t('userSettings.calendarFeed.copied');
|
||||
calendarFeedError.value = null;
|
||||
} catch (error) {
|
||||
console.error('Failed to copy calendar feed URL:', error);
|
||||
calendarFeedStatus.value = null;
|
||||
calendarFeedError.value = t('userSettings.calendarFeed.errors.copyFailed');
|
||||
}
|
||||
}
|
||||
|
||||
async function updateCalendarFeed(action, successMessage) {
|
||||
calendarFeedStatus.value = null;
|
||||
calendarFeedError.value = null;
|
||||
|
||||
try {
|
||||
await action();
|
||||
calendarFeedStatus.value = successMessage;
|
||||
} catch (error) {
|
||||
console.error('Failed to update calendar feed:', error);
|
||||
calendarFeedError.value = t('userSettings.calendarFeed.errors.updateFailed');
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => userProfileStore.user,
|
||||
syncFormFromUser,
|
||||
{ immediate: true, deep: true }
|
||||
);
|
||||
|
||||
userProfileStore.fetchCalendarExportFeed();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -201,6 +258,81 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-heading">
|
||||
<strong>{{ t('userSettings.calendarFeed.title') }}</strong>
|
||||
<span>{{ t('userSettings.calendarFeed.description') }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="calendarFeedError"
|
||||
class="page-message error"
|
||||
>
|
||||
{{ calendarFeedError }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="calendarFeedStatus"
|
||||
class="page-message success"
|
||||
>
|
||||
{{ calendarFeedStatus }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="userProfileStore.calendarExportFeed?.isEnabled && calendarFeedUrl"
|
||||
class="calendar-feed-box"
|
||||
>
|
||||
<span>{{ t('userSettings.calendarFeed.feedUrl') }}</span>
|
||||
<code>{{ calendarFeedUrl }}</code>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="calendar-feed-empty"
|
||||
>
|
||||
{{ t('userSettings.calendarFeed.empty') }}
|
||||
</div>
|
||||
|
||||
<div class="calendar-feed-actions">
|
||||
<button
|
||||
v-if="!userProfileStore.calendarExportFeed?.isEnabled"
|
||||
class="primary-button"
|
||||
type="button"
|
||||
:disabled="userProfileStore.isUpdatingCalendarFeed"
|
||||
@click="enableCalendarFeed"
|
||||
>
|
||||
{{ t('userSettings.calendarFeed.enable') }}
|
||||
</button>
|
||||
|
||||
<template v-else>
|
||||
<button
|
||||
class="secondary-button"
|
||||
type="button"
|
||||
:disabled="!calendarFeedUrl"
|
||||
@click="copyCalendarFeedUrl"
|
||||
>
|
||||
{{ t('userSettings.calendarFeed.copy') }}
|
||||
</button>
|
||||
<button
|
||||
class="secondary-button"
|
||||
type="button"
|
||||
:disabled="userProfileStore.isUpdatingCalendarFeed"
|
||||
@click="regenerateCalendarFeed"
|
||||
>
|
||||
{{ t('userSettings.calendarFeed.regenerate') }}
|
||||
</button>
|
||||
<button
|
||||
class="danger-button"
|
||||
type="button"
|
||||
:disabled="userProfileStore.isUpdatingCalendarFeed"
|
||||
@click="revokeCalendarFeed"
|
||||
>
|
||||
{{ t('userSettings.calendarFeed.revoke') }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ImageCropperDialog
|
||||
v-model="isPortraitDialogOpen"
|
||||
:title="t('userSettings.cropperTitle')"
|
||||
@@ -318,8 +450,47 @@
|
||||
color: #fffaf2;
|
||||
}
|
||||
|
||||
.primary-button:disabled {
|
||||
.secondary-button,
|
||||
.danger-button {
|
||||
@apply inline-flex items-center justify-center gap-2 rounded-full px-5 py-3 text-sm font-bold transition;
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
background: rgba(23, 32, 51, 0.06);
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.danger-button {
|
||||
background: rgba(185, 28, 28, 0.08);
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.primary-button:disabled,
|
||||
.secondary-button:disabled,
|
||||
.danger-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.calendar-feed-box {
|
||||
@apply flex flex-col gap-2 rounded-[1rem] border p-4;
|
||||
background: #fffaf2;
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
.calendar-feed-box span,
|
||||
.calendar-feed-empty {
|
||||
@apply text-sm leading-6;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.calendar-feed-box code {
|
||||
@apply overflow-x-auto rounded-[0.75rem] px-3 py-2 text-sm;
|
||||
background: rgba(23, 32, 51, 0.06);
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.calendar-feed-actions {
|
||||
@apply flex flex-wrap gap-3;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -69,6 +69,33 @@
|
||||
{ key: 'workflow', label: t('workspaceSettings.tabs.workflow'), icon: mdiTuneVariant },
|
||||
{ key: 'connectors', label: t('workspaceSettings.tabs.connectors'), icon: mdiFolderGoogleDrive },
|
||||
]);
|
||||
const activeTabDetail = computed(() => {
|
||||
if (activeTab.value === 'members') {
|
||||
return {
|
||||
title: t('workspaceSettings.tabs.members'),
|
||||
description: t('workspaceSettings.inviteDescription'),
|
||||
};
|
||||
}
|
||||
|
||||
if (activeTab.value === 'workflow') {
|
||||
return {
|
||||
title: t('workspaceSettings.tabs.workflow'),
|
||||
description: t('workspaceSettings.approvals.flowDescription'),
|
||||
};
|
||||
}
|
||||
|
||||
if (activeTab.value === 'connectors') {
|
||||
return {
|
||||
title: t('workspaceSettings.tabs.connectors'),
|
||||
description: t('workspaceSettings.connectors.description'),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: t('workspaceSettings.tabs.general'),
|
||||
description: t('workspaceSettings.general.detailsDescription'),
|
||||
};
|
||||
});
|
||||
const approvalModeOptions = computed(() => [
|
||||
{ value: 'None', label: t('workspaceSettings.approvals.modes.none'), description: t('workspaceSettings.approvals.modeHelp.none') },
|
||||
{ value: 'Optional', label: t('workspaceSettings.approvals.modes.optional'), description: t('workspaceSettings.approvals.modeHelp.optional') },
|
||||
@@ -380,8 +407,16 @@
|
||||
<h1>{{ workspaceStore.activeWorkspace?.name || t('workspaceSettings.noWorkspaceSelected') }}</h1>
|
||||
<p>{{ t('workspaceSettings.description') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-strip">
|
||||
<div
|
||||
v-if="workspaceStore.activeWorkspace"
|
||||
class="workspace-settings-page"
|
||||
>
|
||||
<nav
|
||||
class="tab-strip"
|
||||
aria-label="Workspace settings sections"
|
||||
>
|
||||
<button
|
||||
v-for="tab in settingsTabs"
|
||||
:key="tab.key"
|
||||
@@ -393,38 +428,42 @@
|
||||
<v-icon :icon="tab.icon" />
|
||||
<span>{{ tab.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div
|
||||
v-if="activeTab === 'general'"
|
||||
class="workspace-settings-grid workspace-settings-grid-single"
|
||||
>
|
||||
<article class="settings-card">
|
||||
<div class="section-copy">
|
||||
<span class="section-kicker">{{ t('workspaceSettings.general.detailsTitle') }}</span>
|
||||
<p>{{ t('workspaceSettings.general.detailsDescription') }}</p>
|
||||
<div class="tab-content">
|
||||
<div class="tab-heading">
|
||||
<h2>{{ activeTabDetail.title }}</h2>
|
||||
<p>{{ activeTabDetail.description }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="settingsError"
|
||||
class="page-message error"
|
||||
v-if="activeTab === 'general'"
|
||||
class="workspace-settings-grid workspace-settings-grid-single"
|
||||
>
|
||||
{{ settingsError }}
|
||||
</div>
|
||||
<article class="settings-card">
|
||||
<div class="section-copy">
|
||||
<span class="section-kicker">{{ t('workspaceSettings.general.detailsTitle') }}</span>
|
||||
<p>{{ t('workspaceSettings.general.detailsDescription') }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="settingsStatus"
|
||||
class="page-message success"
|
||||
>
|
||||
{{ settingsStatus }}
|
||||
</div>
|
||||
<div
|
||||
v-if="settingsError"
|
||||
class="page-message error"
|
||||
>
|
||||
{{ settingsError }}
|
||||
</div>
|
||||
|
||||
<form
|
||||
v-if="workspaceStore.activeWorkspace"
|
||||
class="form-stack"
|
||||
@submit.prevent="submitWorkspaceSettings"
|
||||
>
|
||||
<div
|
||||
v-if="settingsStatus"
|
||||
class="page-message success"
|
||||
>
|
||||
{{ settingsStatus }}
|
||||
</div>
|
||||
|
||||
<form
|
||||
class="form-stack"
|
||||
@submit.prevent="submitWorkspaceSettings"
|
||||
>
|
||||
<div class="logo-picker-card">
|
||||
<AppAvatar
|
||||
:name="settingsForm.name || workspaceStore.activeWorkspace.name"
|
||||
@@ -481,22 +520,16 @@
|
||||
>
|
||||
{{ workspaceStore.isUpdating ? t('common.saving') : t('workspaceSettings.general.saveAction') }}
|
||||
</button>
|
||||
</form>
|
||||
</form>
|
||||
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="empty-state"
|
||||
v-else-if="activeTab === 'members'"
|
||||
class="workspace-settings-grid workspace-settings-grid-single"
|
||||
>
|
||||
{{ t('workspaceSettings.noWorkspaceSelected') }}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="activeTab === 'members'"
|
||||
class="workspace-settings-grid workspace-settings-grid-single"
|
||||
>
|
||||
<article class="settings-card">
|
||||
<article class="settings-card">
|
||||
<div class="section-copy">
|
||||
<span class="section-kicker">{{ t('workspaceSettings.members.inviteTitle') }}</span>
|
||||
<p>{{ t('workspaceSettings.inviteDescription') }}</p>
|
||||
@@ -530,9 +563,9 @@
|
||||
{{ workspaceStore.isInviting ? t('common.creating') : t('workspaceSettings.sendInvite') }}
|
||||
</button>
|
||||
</form>
|
||||
</article>
|
||||
</article>
|
||||
|
||||
<article class="settings-card">
|
||||
<article class="settings-card">
|
||||
<div class="section-copy">
|
||||
<span class="section-kicker">{{ t('workspaceSettings.members.pendingTitle') }}</span>
|
||||
<p>{{ t('workspaceSettings.members.pendingDescription') }}</p>
|
||||
@@ -568,9 +601,9 @@
|
||||
>
|
||||
{{ t('workspaceSettings.inviteEmpty') }}
|
||||
</div>
|
||||
</article>
|
||||
</article>
|
||||
|
||||
<article class="settings-card">
|
||||
<article class="settings-card">
|
||||
<div class="section-copy">
|
||||
<span class="section-kicker">{{ t('workspaceSettings.members.activeTitle') }}</span>
|
||||
<p>{{ t('workspaceSettings.members.activeDescription') }}</p>
|
||||
@@ -606,14 +639,14 @@
|
||||
>
|
||||
{{ t('workspaceSettings.members.activeEmpty') }}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="activeTab === 'workflow'"
|
||||
class="workflow-grid"
|
||||
>
|
||||
<article class="settings-card">
|
||||
<div
|
||||
v-else-if="activeTab === 'workflow'"
|
||||
class="workflow-grid"
|
||||
>
|
||||
<article class="settings-card">
|
||||
<div class="section-copy">
|
||||
<span class="section-kicker">{{ t('workspaceSettings.approvals.flowTitle') }}</span>
|
||||
<p>{{ t('workspaceSettings.approvals.flowDescription') }}</p>
|
||||
@@ -709,9 +742,9 @@
|
||||
{{ workspaceStore.isUpdating ? t('common.saving') : t('workspaceSettings.approvals.saveAction') }}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</article>
|
||||
|
||||
<article class="settings-card">
|
||||
<article class="settings-card">
|
||||
<div class="section-copy">
|
||||
<span class="section-kicker">{{ t('workspaceSettings.approvals.previewTitle') }}</span>
|
||||
<p>{{ t('workspaceSettings.approvals.previewDescription') }}</p>
|
||||
@@ -732,14 +765,14 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="workspace-settings-grid"
|
||||
>
|
||||
<article class="settings-card">
|
||||
<div
|
||||
v-else
|
||||
class="workspace-settings-grid"
|
||||
>
|
||||
<article class="settings-card">
|
||||
<div class="section-copy">
|
||||
<span class="section-kicker">{{ t('workspaceSettings.connectors.title') }}</span>
|
||||
<p>{{ t('workspaceSettings.connectors.description') }}</p>
|
||||
@@ -771,7 +804,16 @@
|
||||
<v-icon :icon="mdiImageMultipleOutline" />
|
||||
<span>{{ t('workspaceSettings.connectors.openMediaLibrary') }}</span>
|
||||
</router-link>
|
||||
</article>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="empty-state"
|
||||
>
|
||||
{{ t('workspaceSettings.noWorkspaceSelected') }}
|
||||
</div>
|
||||
|
||||
<ImageCropperDialog
|
||||
@@ -792,11 +834,12 @@
|
||||
}
|
||||
|
||||
.workspace-settings-hero {
|
||||
@apply flex flex-col gap-5 rounded-[1.75rem] border p-5 md:p-6;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(15, 118, 110, 0.16), transparent 38%),
|
||||
linear-gradient(135deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.94));
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
@apply flex flex-col;
|
||||
}
|
||||
|
||||
.workspace-settings-page,
|
||||
.tab-content {
|
||||
@apply flex flex-col gap-4;
|
||||
}
|
||||
|
||||
.workspace-settings-grid {
|
||||
@@ -812,10 +855,9 @@
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
@apply flex flex-col gap-5 rounded-[1.75rem] border p-5;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
@apply flex flex-col gap-5 rounded-[0.75rem] border p-5;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
box-shadow: 0 18px 40px rgba(23, 32, 51, 0.06);
|
||||
}
|
||||
|
||||
.section-copy {
|
||||
@@ -823,26 +865,45 @@
|
||||
}
|
||||
|
||||
.tab-strip {
|
||||
@apply flex flex-wrap gap-3;
|
||||
@apply flex flex-wrap gap-2 border-b pb-3;
|
||||
border-color: rgba(23, 32, 51, 0.1);
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
@apply inline-flex items-center gap-3 rounded-full px-4 py-3 text-sm font-semibold transition;
|
||||
background: rgba(23, 32, 51, 0.06);
|
||||
@apply inline-flex h-10 items-center gap-2 rounded-[0.75rem] px-3 text-sm font-semibold transition-colors;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.tab-button:hover {
|
||||
background: rgba(23, 32, 51, 0.06);
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.tab-button-active {
|
||||
background: #172033;
|
||||
color: #fffaf2;
|
||||
}
|
||||
|
||||
.tab-button :deep(.v-icon) {
|
||||
@apply text-lg;
|
||||
}
|
||||
|
||||
.tab-heading {
|
||||
@apply flex flex-col gap-1;
|
||||
}
|
||||
|
||||
.tab-heading h2 {
|
||||
@apply text-2xl font-black;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.section-kicker {
|
||||
@apply text-xs font-bold uppercase tracking-[0.2em];
|
||||
color: #0f766e;
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.section-copy h1,
|
||||
.tab-heading h2,
|
||||
.invite-row strong,
|
||||
.connector-copy strong,
|
||||
.connector-status,
|
||||
@@ -852,10 +913,11 @@
|
||||
}
|
||||
|
||||
.section-copy h1 {
|
||||
@apply text-3xl font-black;
|
||||
@apply text-3xl font-black md:text-4xl;
|
||||
}
|
||||
|
||||
.section-copy p,
|
||||
.tab-heading p,
|
||||
.invite-row span,
|
||||
.invite-row small,
|
||||
.empty-state,
|
||||
@@ -868,8 +930,8 @@
|
||||
}
|
||||
|
||||
.logo-picker-card {
|
||||
@apply flex flex-col gap-4 rounded-[1rem] border p-4 sm:flex-row sm:items-center;
|
||||
background: #fffaf2;
|
||||
@apply flex flex-col gap-4 rounded-[0.75rem] border p-4 sm:flex-row sm:items-center;
|
||||
background: rgba(23, 32, 51, 0.04);
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
@@ -910,25 +972,40 @@
|
||||
|
||||
.field input,
|
||||
.field select {
|
||||
@apply rounded-[1rem] border px-4 py-3 text-sm;
|
||||
background: #fffdf8;
|
||||
@apply h-11 rounded-[0.5rem] border px-3 text-sm outline-none transition-colors;
|
||||
background: #ffffff;
|
||||
border-color: rgba(23, 32, 51, 0.1);
|
||||
color: #172033;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.field input:focus,
|
||||
.field select:focus {
|
||||
border-color: #0f766e;
|
||||
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.12);
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
@apply inline-flex items-center justify-center rounded-full px-5 py-3 text-sm font-semibold;
|
||||
@apply inline-flex h-11 items-center justify-center rounded-[0.5rem] px-4 text-sm font-bold transition-colors;
|
||||
background: #172033;
|
||||
color: #fffaf2;
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
@apply inline-flex items-center justify-center rounded-full px-4 py-2 text-sm font-semibold;
|
||||
background: rgba(23, 32, 51, 0.08);
|
||||
@apply inline-flex h-11 items-center justify-center rounded-[0.5rem] border px-4 text-sm font-bold transition-colors;
|
||||
background: #ffffff;
|
||||
border-color: rgba(23, 32, 51, 0.14);
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.primary-button:hover:not(:disabled) {
|
||||
background: #0f766e;
|
||||
}
|
||||
|
||||
.secondary-button:hover:not(:disabled) {
|
||||
border-color: #0f766e;
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.primary-button:disabled,
|
||||
.secondary-button:disabled {
|
||||
cursor: not-allowed;
|
||||
@@ -948,8 +1025,8 @@
|
||||
.workflow-rule,
|
||||
.workflow-toggle,
|
||||
.workflow-step {
|
||||
@apply rounded-[1rem] border px-4 py-4;
|
||||
background: #fffaf2;
|
||||
@apply rounded-[0.75rem] border px-4 py-4;
|
||||
background: rgba(23, 32, 51, 0.04);
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
@@ -984,7 +1061,7 @@
|
||||
|
||||
.connector-icon,
|
||||
.workflow-step-icon {
|
||||
@apply inline-flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-2xl;
|
||||
@apply inline-flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-[0.75rem];
|
||||
background: rgba(15, 118, 110, 0.1);
|
||||
color: #0f766e;
|
||||
}
|
||||
@@ -995,12 +1072,12 @@
|
||||
}
|
||||
|
||||
.connector-link {
|
||||
@apply inline-flex w-fit items-center gap-3 rounded-full px-5 py-3 text-sm font-semibold no-underline transition;
|
||||
@apply inline-flex h-11 w-fit items-center gap-3 rounded-[0.5rem] px-4 text-sm font-bold no-underline transition;
|
||||
background: #172033;
|
||||
color: #fffaf2;
|
||||
}
|
||||
|
||||
.connector-link:hover {
|
||||
background: #0f172a;
|
||||
background: #0f766e;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -267,7 +267,11 @@
|
||||
type="button"
|
||||
@click="openOrganizationSettings(activeOrganization.id)"
|
||||
>
|
||||
<span class="organization-mark">{{ activeOrganization.name.slice(0, 1).toUpperCase() }}</span>
|
||||
<AppAvatar
|
||||
:name="activeOrganization.name"
|
||||
:src="activeOrganization.logoUrl"
|
||||
size="sm"
|
||||
/>
|
||||
<span class="user-menu-item-copy">
|
||||
<span>{{ activeOrganizationName }}</span>
|
||||
<small>{{ t('workspaceSelector.organizationLabel') }}</small>
|
||||
@@ -301,7 +305,11 @@
|
||||
type="button"
|
||||
@click="chooseOrganization(organization.id)"
|
||||
>
|
||||
<span class="organization-mark">{{ organization.name.slice(0, 1).toUpperCase() }}</span>
|
||||
<AppAvatar
|
||||
:name="organization.name"
|
||||
:src="organization.logoUrl"
|
||||
size="sm"
|
||||
/>
|
||||
<span class="user-menu-item-copy">
|
||||
<span>{{ organization.name }}</span>
|
||||
<small>{{ t('workspaceSelector.organizationLabel') }}</small>
|
||||
@@ -464,12 +472,6 @@
|
||||
background: rgba(23, 32, 51, 0.07);
|
||||
}
|
||||
|
||||
.organization-mark {
|
||||
@apply flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-[0.8rem] text-xs font-black;
|
||||
background: rgba(23, 32, 51, 0.08);
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.organization-action-icon {
|
||||
@apply flex-shrink-0 text-base;
|
||||
color: #526178;
|
||||
|
||||
@@ -392,12 +392,40 @@
|
||||
"organizationSettings": {
|
||||
"eyebrow": "Organization",
|
||||
"title": "Organization settings",
|
||||
"description": "Manage the SaaS account boundary for members, billing access, connections, and owned workspaces.",
|
||||
"description": "Manage the SaaS account boundary for members, usage, billing access, and connections.",
|
||||
"loading": "Loading organization settings...",
|
||||
"saving": "Saving...",
|
||||
"saveProfile": "Save profile",
|
||||
"editName": "Edit organization name",
|
||||
"saveName": "Save organization name",
|
||||
"cancelNameEdit": "Cancel name edit",
|
||||
"profileSaved": "Organization profile saved.",
|
||||
"addMember": "Add member",
|
||||
"addingMember": "Adding...",
|
||||
"memberAdded": "Organization member added.",
|
||||
"logo": {
|
||||
"title": "Organization logo",
|
||||
"description": "Shown in organization settings and switchers.",
|
||||
"changeAction": "Change logo",
|
||||
"chooseAction": "Choose logo",
|
||||
"cropperTitle": "Edit organization logo",
|
||||
"saveAction": "Save logo",
|
||||
"saving": "Saving...",
|
||||
"saved": "Organization logo saved."
|
||||
},
|
||||
"fields": {
|
||||
"name": "Name",
|
||||
"memberEmail": "Member email",
|
||||
"memberRole": "Role",
|
||||
"createdAt": "Created"
|
||||
},
|
||||
"errors": {
|
||||
"nameRequired": "Organization name is required.",
|
||||
"profileSaveFailed": "The organization profile could not be saved.",
|
||||
"memberRequired": "Email and role are required to add a member.",
|
||||
"memberAddFailed": "The organization member could not be added. Existing users can be added by email.",
|
||||
"logoUploadFailed": "The organization logo could not be saved."
|
||||
},
|
||||
"sections": {
|
||||
"profile": {
|
||||
"title": "Profile",
|
||||
@@ -408,6 +436,13 @@
|
||||
"description": "Organization-level users and their inherited account permissions.",
|
||||
"empty": "No organization members found."
|
||||
},
|
||||
"usage": {
|
||||
"title": "Usage",
|
||||
"description": "Current organization usage against plan limits.",
|
||||
"planLabel": "Current plan",
|
||||
"planFallback": "No plan configured",
|
||||
"empty": "No usage data is available yet."
|
||||
},
|
||||
"billing": {
|
||||
"title": "Billing",
|
||||
"description": "Subscription and billing access for this organization.",
|
||||
@@ -426,6 +461,14 @@
|
||||
"empty": "No workspaces belong to this organization yet."
|
||||
}
|
||||
},
|
||||
"usage": {
|
||||
"unlimited": "Unlimited",
|
||||
"items": {
|
||||
"users": "Users",
|
||||
"workspaces": "Workspaces",
|
||||
"activeContent": "Active content"
|
||||
}
|
||||
},
|
||||
"roles": {
|
||||
"Owner": "Owner",
|
||||
"Admin": "Admin",
|
||||
@@ -820,6 +863,35 @@
|
||||
"empty": "No content items are available for the active workspace.",
|
||||
"noDueDate": "No due date",
|
||||
"assetsHelper": "Google Drive assets are now linked from the content item detail page after creation.",
|
||||
"calendar": {
|
||||
"organization": "Organization",
|
||||
"workspace": "Workspace",
|
||||
"mine": "My calendars",
|
||||
"calendars": "Calendars",
|
||||
"noCalendars": "No calendars available.",
|
||||
"addCalendar": "Add calendar",
|
||||
"alreadyAdded": "Already added",
|
||||
"catalog": "Catalog",
|
||||
"customIcs": "Custom ICS",
|
||||
"searchCatalog": "Search calendars",
|
||||
"search": "Search",
|
||||
"country": "Country",
|
||||
"category": "Category",
|
||||
"calendarName": "Calendar name",
|
||||
"icsUrl": "ICS URL",
|
||||
"allDay": "All day",
|
||||
"context": "Calendar context",
|
||||
"importedEvent": "Imported calendar",
|
||||
"errors": {
|
||||
"required": "Calendar name and URL are required.",
|
||||
"duplicate": "This calendar has already been added.",
|
||||
"createFailed": "The calendar could not be added."
|
||||
}
|
||||
},
|
||||
"dateContext": {
|
||||
"noEvents": "No visible calendar events for this date.",
|
||||
"viewDay": "View day"
|
||||
},
|
||||
"errors": {
|
||||
"required": "Title, campaign, message, and targets are required.",
|
||||
"workspaceAccountRequired": "This workspace needs an operational account before content can be created.",
|
||||
@@ -849,6 +921,24 @@
|
||||
"saveDetails": "Save details",
|
||||
"saved": "Profile details saved",
|
||||
"portraitSaved": "Portrait saved",
|
||||
"calendarFeed": {
|
||||
"title": "Private calendar feed",
|
||||
"description": "Subscribe to your Socialize work dates from external calendar apps.",
|
||||
"empty": "The private calendar feed is disabled.",
|
||||
"feedUrl": "Subscription URL",
|
||||
"enable": "Enable feed",
|
||||
"copy": "Copy URL",
|
||||
"regenerate": "Regenerate URL",
|
||||
"revoke": "Revoke feed",
|
||||
"enabled": "Calendar feed enabled",
|
||||
"regenerated": "Calendar feed URL regenerated",
|
||||
"revoked": "Calendar feed revoked",
|
||||
"copied": "Calendar feed URL copied",
|
||||
"errors": {
|
||||
"copyFailed": "The URL could not be copied.",
|
||||
"updateFailed": "The calendar feed could not be updated."
|
||||
}
|
||||
},
|
||||
"alias": "Alias",
|
||||
"firstname": "First name",
|
||||
"lastname": "Last name",
|
||||
|
||||
@@ -392,12 +392,40 @@
|
||||
"organizationSettings": {
|
||||
"eyebrow": "Organisation",
|
||||
"title": "Parametres de l'organisation",
|
||||
"description": "Gerez le compte SaaS pour les membres, la facturation, les connexions et les espaces detenus.",
|
||||
"description": "Gerez le compte SaaS pour les membres, l'utilisation, la facturation et les connexions.",
|
||||
"loading": "Chargement des parametres de l'organisation...",
|
||||
"saving": "Enregistrement...",
|
||||
"saveProfile": "Enregistrer le profil",
|
||||
"editName": "Modifier le nom de l'organisation",
|
||||
"saveName": "Enregistrer le nom de l'organisation",
|
||||
"cancelNameEdit": "Annuler la modification du nom",
|
||||
"profileSaved": "Profil de l'organisation enregistre.",
|
||||
"addMember": "Ajouter un membre",
|
||||
"addingMember": "Ajout...",
|
||||
"memberAdded": "Membre de l'organisation ajoute.",
|
||||
"logo": {
|
||||
"title": "Logo de l'organisation",
|
||||
"description": "Affiche dans les parametres et les selecteurs d'organisation.",
|
||||
"changeAction": "Changer le logo",
|
||||
"chooseAction": "Choisir un logo",
|
||||
"cropperTitle": "Modifier le logo de l'organisation",
|
||||
"saveAction": "Enregistrer le logo",
|
||||
"saving": "Enregistrement...",
|
||||
"saved": "Logo de l'organisation enregistre."
|
||||
},
|
||||
"fields": {
|
||||
"name": "Nom",
|
||||
"memberEmail": "Email du membre",
|
||||
"memberRole": "Role",
|
||||
"createdAt": "Cree"
|
||||
},
|
||||
"errors": {
|
||||
"nameRequired": "Le nom de l'organisation est requis.",
|
||||
"profileSaveFailed": "Le profil de l'organisation n'a pas pu etre enregistre.",
|
||||
"memberRequired": "L'email et le role sont requis pour ajouter un membre.",
|
||||
"memberAddFailed": "Le membre de l'organisation n'a pas pu etre ajoute. Les utilisateurs existants peuvent etre ajoutes par email.",
|
||||
"logoUploadFailed": "Le logo de l'organisation n'a pas pu etre enregistre."
|
||||
},
|
||||
"sections": {
|
||||
"profile": {
|
||||
"title": "Profil",
|
||||
@@ -408,6 +436,13 @@
|
||||
"description": "Utilisateurs de l'organisation et leurs permissions heritees.",
|
||||
"empty": "Aucun membre d'organisation trouve."
|
||||
},
|
||||
"usage": {
|
||||
"title": "Utilisation",
|
||||
"description": "Utilisation actuelle de l'organisation par rapport aux limites du forfait.",
|
||||
"planLabel": "Forfait actuel",
|
||||
"planFallback": "Aucun forfait configure",
|
||||
"empty": "Aucune donnee d'utilisation n'est disponible pour le moment."
|
||||
},
|
||||
"billing": {
|
||||
"title": "Facturation",
|
||||
"description": "Acces a l'abonnement et a la facturation de cette organisation.",
|
||||
@@ -426,6 +461,14 @@
|
||||
"empty": "Aucun espace n'appartient encore a cette organisation."
|
||||
}
|
||||
},
|
||||
"usage": {
|
||||
"unlimited": "Illimite",
|
||||
"items": {
|
||||
"users": "Utilisateurs",
|
||||
"workspaces": "Espaces",
|
||||
"activeContent": "Contenu actif"
|
||||
}
|
||||
},
|
||||
"roles": {
|
||||
"Owner": "Proprietaire",
|
||||
"Admin": "Administrateur",
|
||||
@@ -820,6 +863,35 @@
|
||||
"empty": "Aucun élément de contenu n'est disponible pour l'espace actif.",
|
||||
"noDueDate": "Aucune échéance",
|
||||
"assetsHelper": "Les ressources Google Drive sont maintenant liées depuis la page de détail de l'élément après sa création.",
|
||||
"calendar": {
|
||||
"organization": "Organisation",
|
||||
"workspace": "Espace",
|
||||
"mine": "Mes calendriers",
|
||||
"calendars": "Calendriers",
|
||||
"noCalendars": "Aucun calendrier disponible.",
|
||||
"addCalendar": "Ajouter un calendrier",
|
||||
"alreadyAdded": "Déjà ajouté",
|
||||
"catalog": "Catalogue",
|
||||
"customIcs": "ICS personnalisé",
|
||||
"searchCatalog": "Rechercher des calendriers",
|
||||
"search": "Rechercher",
|
||||
"country": "Pays",
|
||||
"category": "Catégorie",
|
||||
"calendarName": "Nom du calendrier",
|
||||
"icsUrl": "URL ICS",
|
||||
"allDay": "Toute la journée",
|
||||
"context": "Contexte calendrier",
|
||||
"importedEvent": "Calendrier importé",
|
||||
"errors": {
|
||||
"required": "Le nom et l'URL du calendrier sont requis.",
|
||||
"duplicate": "Ce calendrier a déjà été ajouté.",
|
||||
"createFailed": "Le calendrier n'a pas pu être ajouté."
|
||||
}
|
||||
},
|
||||
"dateContext": {
|
||||
"noEvents": "Aucun événement de calendrier visible pour cette date.",
|
||||
"viewDay": "Voir la journée"
|
||||
},
|
||||
"errors": {
|
||||
"required": "Le titre, la campagne, le message et les cibles sont requis.",
|
||||
"workspaceAccountRequired": "Cet espace a besoin d'un compte opérationnel avant de créer du contenu.",
|
||||
@@ -849,6 +921,24 @@
|
||||
"saveDetails": "Enregistrer les détails",
|
||||
"saved": "Informations de profil enregistrées",
|
||||
"portraitSaved": "Portrait enregistré",
|
||||
"calendarFeed": {
|
||||
"title": "Flux calendrier privé",
|
||||
"description": "Abonnez vos apps calendrier externes à vos dates de travail Socialize.",
|
||||
"empty": "Le flux calendrier privé est désactivé.",
|
||||
"feedUrl": "URL d'abonnement",
|
||||
"enable": "Activer le flux",
|
||||
"copy": "Copier l'URL",
|
||||
"regenerate": "Régénérer l'URL",
|
||||
"revoke": "Révoquer le flux",
|
||||
"enabled": "Flux calendrier activé",
|
||||
"regenerated": "URL du flux calendrier régénérée",
|
||||
"revoked": "Flux calendrier révoqué",
|
||||
"copied": "URL du flux calendrier copiée",
|
||||
"errors": {
|
||||
"copyFailed": "L'URL n'a pas pu être copiée.",
|
||||
"updateFailed": "Le flux calendrier n'a pas pu être mis à jour."
|
||||
}
|
||||
},
|
||||
"alias": "Alias",
|
||||
"firstname": "Prénom",
|
||||
"lastname": "Nom",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
dotnet run --project backend/src/Socialize.Api/Socialize.Api.csproj --urls http://0.0.0.0:5080
|
||||
dotnet watch --project backend/src/Socialize.Api/Socialize.Api.csproj --urls http://0.0.0.0:5080
|
||||
|
||||
@@ -1,12 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
CONTAINER="socialize-postgres"
|
||||
API_PROJECT="${REPO_ROOT}/backend/src/Socialize.Api/Socialize.Api.csproj"
|
||||
MIGRATIONS_DIR="${REPO_ROOT}/backend/src/Socialize.Api/Migrations"
|
||||
|
||||
echo "Removing existing EF Core migrations..."
|
||||
rm -rf "$MIGRATIONS_DIR"
|
||||
mkdir -p "$MIGRATIONS_DIR"
|
||||
|
||||
echo "Creating fresh Initial migration..."
|
||||
dotnet ef migrations add Initial \
|
||||
--context AppDbContext \
|
||||
--configuration Debug \
|
||||
--project "$API_PROJECT" \
|
||||
--startup-project "$API_PROJECT" \
|
||||
--output-dir Migrations
|
||||
|
||||
if docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER}$"; then
|
||||
docker stop "$CONTAINER"
|
||||
docker rm "$CONTAINER"
|
||||
fi
|
||||
|
||||
./scripts/start-infrastructure.sh
|
||||
./scripts/dev-backend.sh
|
||||
"${REPO_ROOT}/scripts/start-infrastructure.sh"
|
||||
"${REPO_ROOT}/scripts/dev-backend.sh"
|
||||
|
||||
@@ -385,6 +385,69 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/organizations/{organizationId}/members": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Organizations",
|
||||
"Api"
|
||||
],
|
||||
"operationId": "SocializeApiModulesOrganizationsHandlersAddOrganizationMemberHandler",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "organizationId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "guid"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"x-name": "AddOrganizationMemberRequest",
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SocializeApiModulesOrganizationsHandlersAddOrganizationMemberRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true,
|
||||
"x-position": 1
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SocializeApiModulesOrganizationsHandlersOrganizationMemberDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/FastEndpointsErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"JWTBearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/organizations/{organizationId}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -423,6 +486,67 @@
|
||||
"JWTBearerAuth": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"put": {
|
||||
"tags": [
|
||||
"Organizations",
|
||||
"Api"
|
||||
],
|
||||
"operationId": "SocializeApiModulesOrganizationsHandlersUpdateOrganizationHandler",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "organizationId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "guid"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"x-name": "UpdateOrganizationRequest",
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SocializeApiModulesOrganizationsHandlersUpdateOrganizationRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true,
|
||||
"x-position": 1
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SocializeApiModulesOrganizationsHandlersOrganizationDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/FastEndpointsErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"JWTBearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/organizations": {
|
||||
@@ -2734,6 +2858,193 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/calendar-integrations/sources": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Calendar Integrations",
|
||||
"Api"
|
||||
],
|
||||
"operationId": "SocializeApiModulesCalendarIntegrationsHandlersCreateCalendarSourceHandler",
|
||||
"requestBody": {
|
||||
"x-name": "UpsertCalendarSourceRequest",
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SocializeApiModulesCalendarIntegrationsHandlersUpsertCalendarSourceRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true,
|
||||
"x-position": 1
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SocializeApiModulesCalendarIntegrationsHandlersCalendarSourceDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/FastEndpointsErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"JWTBearerAuth": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"get": {
|
||||
"tags": [
|
||||
"Calendar Integrations",
|
||||
"Api"
|
||||
],
|
||||
"operationId": "SocializeApiModulesCalendarIntegrationsHandlersListCalendarSourcesHandler",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "workspaceId",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "guid",
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/SocializeApiModulesCalendarIntegrationsHandlersCalendarSourceDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"JWTBearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/calendar-integrations/sources/{sourceId}": {
|
||||
"delete": {
|
||||
"tags": [
|
||||
"Calendar Integrations",
|
||||
"Api"
|
||||
],
|
||||
"operationId": "SocializeApiModulesCalendarIntegrationsHandlersDeleteCalendarSourceHandler",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sourceId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "guid"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"JWTBearerAuth": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"put": {
|
||||
"tags": [
|
||||
"Calendar Integrations",
|
||||
"Api"
|
||||
],
|
||||
"operationId": "SocializeApiModulesCalendarIntegrationsHandlersUpdateCalendarSourceHandler",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sourceId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "guid"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"x-name": "UpsertCalendarSourceRequest",
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SocializeApiModulesCalendarIntegrationsHandlersUpsertCalendarSourceRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true,
|
||||
"x-position": 1
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SocializeApiModulesCalendarIntegrationsHandlersCalendarSourceDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/FastEndpointsErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"JWTBearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/assets/{id}/revisions": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@@ -3296,6 +3607,62 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"SocializeApiModulesOrganizationsHandlersOrganizationMemberDto": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"userId": {
|
||||
"type": "string",
|
||||
"format": "guid"
|
||||
},
|
||||
"displayName": {
|
||||
"type": "string"
|
||||
},
|
||||
"email": {
|
||||
"type": "string"
|
||||
},
|
||||
"portraitUrl": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"role": {
|
||||
"type": "string"
|
||||
},
|
||||
"permissions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SocializeApiModulesOrganizationsHandlersAddOrganizationMemberRequest": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"email",
|
||||
"role"
|
||||
],
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string",
|
||||
"format": "email",
|
||||
"maxLength": 256,
|
||||
"minLength": 0,
|
||||
"pattern": "^[^@]+@[^@]+$",
|
||||
"nullable": false
|
||||
},
|
||||
"role": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"nullable": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"SocializeApiModulesOrganizationsHandlersOrganizationDto": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
@@ -3335,36 +3702,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"SocializeApiModulesOrganizationsHandlersOrganizationMemberDto": {
|
||||
"SocializeApiModulesOrganizationsHandlersUpdateOrganizationRequest": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"properties": {
|
||||
"userId": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"format": "guid"
|
||||
},
|
||||
"displayName": {
|
||||
"type": "string"
|
||||
},
|
||||
"email": {
|
||||
"type": "string"
|
||||
},
|
||||
"portraitUrl": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"role": {
|
||||
"type": "string"
|
||||
},
|
||||
"permissions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
"maxLength": 256,
|
||||
"minLength": 0,
|
||||
"nullable": false
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4880,6 +5229,151 @@
|
||||
"type": "object",
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SocializeApiModulesCalendarIntegrationsHandlersCalendarSourceDto": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"format": "guid"
|
||||
},
|
||||
"scope": {
|
||||
"type": "string"
|
||||
},
|
||||
"organizationId": {
|
||||
"type": "string",
|
||||
"format": "guid",
|
||||
"nullable": true
|
||||
},
|
||||
"workspaceId": {
|
||||
"type": "string",
|
||||
"format": "guid",
|
||||
"nullable": true
|
||||
},
|
||||
"userId": {
|
||||
"type": "string",
|
||||
"format": "guid",
|
||||
"nullable": true
|
||||
},
|
||||
"sourceUrl": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"catalogSourceReference": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"displayTitle": {
|
||||
"type": "string"
|
||||
},
|
||||
"color": {
|
||||
"type": "string"
|
||||
},
|
||||
"category": {
|
||||
"type": "string"
|
||||
},
|
||||
"isEnabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"inheritanceMode": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"isReadOnly": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"lastSuccessfulSyncAt": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"nullable": true
|
||||
},
|
||||
"lastAttemptedSyncAt": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"nullable": true
|
||||
},
|
||||
"lastSyncError": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SocializeApiModulesCalendarIntegrationsHandlersUpsertCalendarSourceRequest": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"scope",
|
||||
"displayTitle",
|
||||
"color",
|
||||
"category"
|
||||
],
|
||||
"properties": {
|
||||
"scope": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"nullable": false
|
||||
},
|
||||
"organizationId": {
|
||||
"type": "string",
|
||||
"format": "guid",
|
||||
"nullable": true
|
||||
},
|
||||
"workspaceId": {
|
||||
"type": "string",
|
||||
"format": "guid",
|
||||
"nullable": true
|
||||
},
|
||||
"sourceUrl": {
|
||||
"type": "string",
|
||||
"maxLength": 2048,
|
||||
"minLength": 0,
|
||||
"nullable": true
|
||||
},
|
||||
"catalogSourceReference": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"minLength": 0,
|
||||
"nullable": true
|
||||
},
|
||||
"displayTitle": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"minLength": 0,
|
||||
"nullable": false
|
||||
},
|
||||
"color": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "^#[0-9A-Fa-f]{6}$",
|
||||
"nullable": false
|
||||
},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"maxLength": 64,
|
||||
"minLength": 0,
|
||||
"nullable": false
|
||||
},
|
||||
"isEnabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"inheritanceMode": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"SocializeApiModulesCalendarIntegrationsHandlersListCalendarSourcesRequest": {
|
||||
"type": "object",
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SocializeApiModulesAssetsHandlersAssetRevisionDto": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
|
||||
Reference in New Issue
Block a user