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