Add calendar integrations and collaboration updates
Some checks failed
Backend CI/CD / build_and_deploy (push) Has been cancelled
Frontend CI/CD / build_and_deploy (push) Has been cancelled

This commit is contained in:
2026-05-05 15:25:53 -04:00
parent c49f03ec06
commit b66c10b681
82 changed files with 8420 additions and 2048 deletions

View File

@@ -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();
}
}

View File

@@ -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";

View File

@@ -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

View File

@@ -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");
}
}
}

View File

@@ -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")

View File

@@ -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");

View File

@@ -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")

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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; }
}

View File

@@ -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",
},
];
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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;
}
}

View File

@@ -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; }
}

View File

@@ -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;
}
}

View File

@@ -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.");
}
}

View File

@@ -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();
}
}

View File

@@ -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,
};
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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,
};
}
}

View File

@@ -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();
}
}

View File

@@ -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";
}
}

View File

@@ -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(",", "\\,");
}
}

View File

@@ -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);
}
}

View File

@@ -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('/', '_');
}
}

View File

@@ -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.");
}
}
}

View File

@@ -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);
}

View File

@@ -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";
}

View File

@@ -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,
};
}
}

View File

@@ -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),
};
}
}

View File

@@ -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; }
}

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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; }
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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,

View File

@@ -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,
});
}
}

View File

@@ -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);
}
}

View File

@@ -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,

View File

@@ -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);
}
}

View File

@@ -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; }
}

View File

@@ -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");

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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))

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -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();

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View 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.

View 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.

View File

@@ -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
```

View File

@@ -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;

View File

@@ -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,
};
});

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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,
};
});

View File

@@ -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>

View File

@@ -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
}
})

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;

View File

@@ -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",

View File

@@ -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",

View File

@@ -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

View File

@@ -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"

View File

@@ -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,