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

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