Add calendar integrations and collaboration updates
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
using Socialize.Api.Modules.CalendarIntegrations.Data;
|
||||
using Socialize.Api.Modules.CalendarIntegrations.Services;
|
||||
|
||||
namespace Socialize.Api.Modules.CalendarIntegrations.Handlers;
|
||||
|
||||
public record CalendarSourceDto(
|
||||
Guid Id,
|
||||
string Scope,
|
||||
Guid? OrganizationId,
|
||||
Guid? WorkspaceId,
|
||||
Guid? UserId,
|
||||
string? SourceUrl,
|
||||
string? CatalogSourceReference,
|
||||
string DisplayTitle,
|
||||
string Color,
|
||||
string Category,
|
||||
bool IsEnabled,
|
||||
string? InheritanceMode,
|
||||
bool IsReadOnly,
|
||||
DateTimeOffset? LastSuccessfulSyncAt,
|
||||
DateTimeOffset? LastAttemptedSyncAt,
|
||||
string? LastSyncError,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt)
|
||||
{
|
||||
public static CalendarSourceDto FromSource(CalendarSource source, bool isReadOnly)
|
||||
{
|
||||
return new CalendarSourceDto(
|
||||
source.Id,
|
||||
source.Scope,
|
||||
source.OrganizationId,
|
||||
source.WorkspaceId,
|
||||
source.UserId,
|
||||
source.SourceUrl,
|
||||
source.CatalogSourceReference,
|
||||
source.DisplayTitle,
|
||||
source.Color,
|
||||
source.Category,
|
||||
source.IsEnabled,
|
||||
source.InheritanceMode,
|
||||
isReadOnly,
|
||||
source.LastSuccessfulSyncAt,
|
||||
source.LastAttemptedSyncAt,
|
||||
source.LastSyncError,
|
||||
source.CreatedAt,
|
||||
source.UpdatedAt);
|
||||
}
|
||||
}
|
||||
|
||||
public record UpsertCalendarSourceRequest(
|
||||
string Scope,
|
||||
Guid? OrganizationId,
|
||||
Guid? WorkspaceId,
|
||||
string? SourceUrl,
|
||||
string? CatalogSourceReference,
|
||||
string DisplayTitle,
|
||||
string Color,
|
||||
string Category,
|
||||
bool IsEnabled,
|
||||
string? InheritanceMode);
|
||||
|
||||
public class UpsertCalendarSourceRequestValidator
|
||||
: FastEndpoints.Validator<UpsertCalendarSourceRequest>
|
||||
{
|
||||
public UpsertCalendarSourceRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Scope)
|
||||
.NotEmpty()
|
||||
.Must(CalendarSourceRules.IsSupportedScope)
|
||||
.WithMessage("A valid calendar source scope should be specified.");
|
||||
|
||||
RuleFor(x => x.DisplayTitle).NotEmpty().MaximumLength(256);
|
||||
RuleFor(x => x.Category).NotEmpty().MaximumLength(64);
|
||||
RuleFor(x => x.Color)
|
||||
.NotEmpty()
|
||||
.Matches("^#[0-9A-Fa-f]{6}$")
|
||||
.WithMessage("Color should be a six digit hex color, for example #2F80ED.");
|
||||
|
||||
RuleFor(x => x.SourceUrl)
|
||||
.MaximumLength(2048)
|
||||
.Must(value => string.IsNullOrWhiteSpace(value) || Uri.TryCreate(value, UriKind.Absolute, out Uri? uri) &&
|
||||
(uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
|
||||
.WithMessage("Source URL should be an absolute HTTP or HTTPS URL.");
|
||||
|
||||
RuleFor(x => x.CatalogSourceReference).MaximumLength(256);
|
||||
|
||||
RuleFor(x => x)
|
||||
.Must(x => !string.IsNullOrWhiteSpace(x.SourceUrl) || !string.IsNullOrWhiteSpace(x.CatalogSourceReference))
|
||||
.WithMessage("A source URL or catalog source reference should be specified.");
|
||||
|
||||
RuleFor(x => x)
|
||||
.Must(x => x.Scope != CalendarSourceScopes.Organization || x.OrganizationId.HasValue)
|
||||
.WithMessage("Organization calendar sources require an organization id.");
|
||||
|
||||
RuleFor(x => x)
|
||||
.Must(x => x.Scope != CalendarSourceScopes.Workspace || x.WorkspaceId.HasValue)
|
||||
.WithMessage("Workspace calendar sources require a workspace id.");
|
||||
|
||||
RuleFor(x => x)
|
||||
.Must(x => x.Scope != CalendarSourceScopes.User || (!x.OrganizationId.HasValue && !x.WorkspaceId.HasValue))
|
||||
.WithMessage("User calendar sources should not include organization or workspace ids.");
|
||||
|
||||
RuleFor(x => x)
|
||||
.Must(x => x.Scope == CalendarSourceScopes.Organization ||
|
||||
(!x.OrganizationId.HasValue && string.IsNullOrWhiteSpace(x.InheritanceMode)))
|
||||
.WithMessage("Only organization calendar sources can set organization ids or inheritance modes.");
|
||||
|
||||
RuleFor(x => x.InheritanceMode)
|
||||
.Must(value => string.IsNullOrWhiteSpace(value) || CalendarSourceRules.IsSupportedInheritanceMode(value))
|
||||
.WithMessage("A valid inheritance mode should be specified.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Infrastructure.Security;
|
||||
using Socialize.Api.Modules.CalendarIntegrations.Data;
|
||||
using Socialize.Api.Modules.CalendarIntegrations.Services;
|
||||
using Socialize.Api.Modules.Organizations.Services;
|
||||
|
||||
namespace Socialize.Api.Modules.CalendarIntegrations.Handlers;
|
||||
|
||||
public class CreateCalendarSourceHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService,
|
||||
OrganizationAccessService organizationAccessService)
|
||||
: Endpoint<UpsertCalendarSourceRequest, CalendarSourceDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/calendar-integrations/sources");
|
||||
Options(o => o.WithTags("Calendar Integrations"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(UpsertCalendarSourceRequest request, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
Guid currentUserId = User.GetUserId();
|
||||
string scope = request.Scope.Trim();
|
||||
Guid? organizationId = request.OrganizationId;
|
||||
Guid? workspaceId = request.WorkspaceId;
|
||||
|
||||
if (!await CanCreateAsync(scope, organizationId, workspaceId, currentUserId, ct))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
string? sourceUrl = NormalizeOptional(request.SourceUrl);
|
||||
string? catalogSourceReference = NormalizeOptional(request.CatalogSourceReference);
|
||||
if (await SourceAlreadyExistsAsync(scope, organizationId, workspaceId, currentUserId, sourceUrl, catalogSourceReference, ct))
|
||||
{
|
||||
AddError(request => request.SourceUrl, "This calendar source has already been added.");
|
||||
await SendErrorsAsync(cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
CalendarSource source = new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Scope = scope,
|
||||
OrganizationId = scope == CalendarSourceScopes.Organization ? organizationId : null,
|
||||
WorkspaceId = scope == CalendarSourceScopes.Workspace ? workspaceId : null,
|
||||
UserId = scope == CalendarSourceScopes.User ? currentUserId : null,
|
||||
SourceUrl = sourceUrl,
|
||||
CatalogSourceReference = catalogSourceReference,
|
||||
DisplayTitle = request.DisplayTitle.Trim(),
|
||||
Color = request.Color.Trim(),
|
||||
Category = request.Category.Trim(),
|
||||
IsEnabled = request.IsEnabled,
|
||||
InheritanceMode = scope == CalendarSourceScopes.Organization
|
||||
? NormalizeOptional(request.InheritanceMode) ?? CalendarSourceInheritanceModes.Optional
|
||||
: null,
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
dbContext.CalendarSources.Add(source);
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
|
||||
await SendAsync(CalendarSourceDto.FromSource(source, isReadOnly: false), StatusCodes.Status201Created, ct);
|
||||
}
|
||||
|
||||
private async Task<bool> CanCreateAsync(
|
||||
string scope,
|
||||
Guid? organizationId,
|
||||
Guid? workspaceId,
|
||||
Guid currentUserId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
return scope switch
|
||||
{
|
||||
CalendarSourceScopes.Organization when organizationId.HasValue =>
|
||||
await dbContext.Organizations.AnyAsync(organization => organization.Id == organizationId.Value, ct) &&
|
||||
await organizationAccessService.HasOrganizationPermissionAsync(
|
||||
User,
|
||||
organizationId.Value,
|
||||
OrganizationPermissions.ManageConnectors,
|
||||
ct),
|
||||
CalendarSourceScopes.Workspace when workspaceId.HasValue =>
|
||||
await dbContext.Workspaces.AnyAsync(workspace => workspace.Id == workspaceId.Value, ct) &&
|
||||
await accessScopeService.CanManageWorkspaceAsync(User, workspaceId.Value, ct),
|
||||
CalendarSourceScopes.User => currentUserId != Guid.Empty,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
private Task<bool> SourceAlreadyExistsAsync(
|
||||
string scope,
|
||||
Guid? organizationId,
|
||||
Guid? workspaceId,
|
||||
Guid currentUserId,
|
||||
string? sourceUrl,
|
||||
string? catalogSourceReference,
|
||||
CancellationToken ct)
|
||||
{
|
||||
IQueryable<CalendarSource> query = dbContext.CalendarSources
|
||||
.Where(source => source.Scope == scope);
|
||||
|
||||
query = scope switch
|
||||
{
|
||||
CalendarSourceScopes.Organization => query.Where(source => source.OrganizationId == organizationId),
|
||||
CalendarSourceScopes.Workspace => query.Where(source => source.WorkspaceId == workspaceId),
|
||||
CalendarSourceScopes.User => query.Where(source => source.UserId == currentUserId),
|
||||
_ => query.Where(_ => false),
|
||||
};
|
||||
|
||||
string? normalizedUrl = sourceUrl?.Trim();
|
||||
string? normalizedCatalogReference = catalogSourceReference?.Trim();
|
||||
|
||||
return query.AnyAsync(source =>
|
||||
(!string.IsNullOrWhiteSpace(normalizedCatalogReference) &&
|
||||
source.CatalogSourceReference == normalizedCatalogReference) ||
|
||||
(!string.IsNullOrWhiteSpace(normalizedUrl) &&
|
||||
source.SourceUrl != null &&
|
||||
source.SourceUrl.ToUpper() == normalizedUrl.ToUpper()),
|
||||
ct);
|
||||
}
|
||||
|
||||
private static string? NormalizeOptional(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Infrastructure.Security;
|
||||
using Socialize.Api.Modules.CalendarIntegrations.Data;
|
||||
using Socialize.Api.Modules.CalendarIntegrations.Services;
|
||||
using Socialize.Api.Modules.Organizations.Services;
|
||||
|
||||
namespace Socialize.Api.Modules.CalendarIntegrations.Handlers;
|
||||
|
||||
public class DeleteCalendarSourceHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService,
|
||||
OrganizationAccessService organizationAccessService)
|
||||
: EndpointWithoutRequest
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Delete("/api/calendar-integrations/sources/{sourceId:guid}");
|
||||
Options(o => o.WithTags("Calendar Integrations"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
Guid sourceId = Route<Guid>("sourceId");
|
||||
CalendarSource? source = await dbContext.CalendarSources.SingleOrDefaultAsync(candidate => candidate.Id == sourceId, ct);
|
||||
if (source is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await CanManageExistingSourceAsync(source, User.GetUserId(), ct))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
dbContext.CalendarSources.Remove(source);
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
|
||||
await SendNoContentAsync(ct);
|
||||
}
|
||||
|
||||
private async Task<bool> CanManageExistingSourceAsync(
|
||||
CalendarSource source,
|
||||
Guid currentUserId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
return source.Scope switch
|
||||
{
|
||||
CalendarSourceScopes.Organization when source.OrganizationId.HasValue =>
|
||||
await organizationAccessService.HasOrganizationPermissionAsync(
|
||||
User,
|
||||
source.OrganizationId.Value,
|
||||
OrganizationPermissions.ManageConnectors,
|
||||
ct),
|
||||
CalendarSourceScopes.Workspace when source.WorkspaceId.HasValue =>
|
||||
await accessScopeService.CanManageWorkspaceAsync(User, source.WorkspaceId.Value, ct),
|
||||
CalendarSourceScopes.User => source.UserId == currentUserId,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Modules.CalendarIntegrations.Data;
|
||||
|
||||
namespace Socialize.Api.Modules.CalendarIntegrations.Handlers;
|
||||
|
||||
public sealed class ListCalendarCatalogRequest
|
||||
{
|
||||
public string? Search { get; set; }
|
||||
public string? Country { get; set; }
|
||||
public string? Region { get; set; }
|
||||
public string? Language { get; set; }
|
||||
public string? Category { get; set; }
|
||||
public string? CultureOrReligion { get; set; }
|
||||
public string? Provider { get; set; }
|
||||
}
|
||||
|
||||
public record CalendarCatalogEntryDto(
|
||||
Guid Id,
|
||||
string Title,
|
||||
string Description,
|
||||
string? Country,
|
||||
string? Region,
|
||||
string Language,
|
||||
string Category,
|
||||
string? CultureOrReligion,
|
||||
string ProviderName,
|
||||
string SourceUrl,
|
||||
string TrustLevel,
|
||||
string DefaultColor);
|
||||
|
||||
public class ListCalendarCatalogHandler(AppDbContext dbContext)
|
||||
: Endpoint<ListCalendarCatalogRequest, IReadOnlyCollection<CalendarCatalogEntryDto>>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/calendar-integrations/catalog");
|
||||
Options(o => o.WithTags("Calendar Integrations"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(ListCalendarCatalogRequest request, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
IQueryable<CalendarCatalogEntry> query = dbContext.CalendarCatalogEntries.AsQueryable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Search))
|
||||
{
|
||||
string search = request.Search.Trim().ToLowerInvariant();
|
||||
query = query.Where(entry =>
|
||||
entry.Title.ToLower().Contains(search) ||
|
||||
entry.Description.ToLower().Contains(search) ||
|
||||
entry.ProviderName.ToLower().Contains(search));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Country))
|
||||
{
|
||||
string country = request.Country.Trim().ToUpperInvariant();
|
||||
query = query.Where(entry => entry.Country == country);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Region))
|
||||
{
|
||||
string region = request.Region.Trim();
|
||||
query = query.Where(entry => entry.Region == region);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Language))
|
||||
{
|
||||
string language = request.Language.Trim();
|
||||
query = query.Where(entry => entry.Language == language);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Category))
|
||||
{
|
||||
string category = request.Category.Trim();
|
||||
query = query.Where(entry => entry.Category == category);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.CultureOrReligion))
|
||||
{
|
||||
string cultureOrReligion = request.CultureOrReligion.Trim();
|
||||
query = query.Where(entry => entry.CultureOrReligion == cultureOrReligion);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Provider))
|
||||
{
|
||||
string provider = request.Provider.Trim();
|
||||
query = query.Where(entry => entry.ProviderName == provider);
|
||||
}
|
||||
|
||||
CalendarCatalogEntryDto[] entries = await query
|
||||
.OrderBy(entry => entry.Country)
|
||||
.ThenBy(entry => entry.Category)
|
||||
.ThenBy(entry => entry.Title)
|
||||
.Take(100)
|
||||
.Select(entry => new CalendarCatalogEntryDto(
|
||||
entry.Id,
|
||||
entry.Title,
|
||||
entry.Description,
|
||||
entry.Country,
|
||||
entry.Region,
|
||||
entry.Language,
|
||||
entry.Category,
|
||||
entry.CultureOrReligion,
|
||||
entry.ProviderName,
|
||||
entry.SourceUrl,
|
||||
entry.TrustLevel,
|
||||
entry.DefaultColor))
|
||||
.ToArrayAsync(ct);
|
||||
|
||||
await SendOkAsync(entries, ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Infrastructure.Security;
|
||||
using Socialize.Api.Modules.CalendarIntegrations.Data;
|
||||
using Socialize.Api.Modules.CalendarIntegrations.Services;
|
||||
|
||||
namespace Socialize.Api.Modules.CalendarIntegrations.Handlers;
|
||||
|
||||
public sealed class ListCalendarEventsRequest
|
||||
{
|
||||
public Guid? WorkspaceId { get; set; }
|
||||
public DateOnly? StartDate { get; set; }
|
||||
public DateOnly? EndDate { get; set; }
|
||||
}
|
||||
|
||||
public record CalendarEventDto(
|
||||
Guid Id,
|
||||
Guid CalendarSourceId,
|
||||
string SourceEventUid,
|
||||
string Title,
|
||||
string? Description,
|
||||
bool IsAllDay,
|
||||
bool IsFloatingTime,
|
||||
DateOnly StartDate,
|
||||
DateOnly EndDate,
|
||||
DateTime? StartLocalDateTime,
|
||||
DateTime? EndLocalDateTime,
|
||||
DateTimeOffset? StartUtc,
|
||||
DateTimeOffset? EndUtc,
|
||||
string? TimeZoneId,
|
||||
string? RecurrenceId,
|
||||
string? Location,
|
||||
string? SourceUrl,
|
||||
DateTimeOffset? SourceLastModifiedAt,
|
||||
DateTimeOffset ImportedAt);
|
||||
|
||||
public class ListCalendarEventsHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService)
|
||||
: Endpoint<ListCalendarEventsRequest, IReadOnlyCollection<CalendarEventDto>>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/calendar-integrations/events");
|
||||
Options(o => o.WithTags("Calendar Integrations"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(ListCalendarEventsRequest request, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
Guid currentUserId = User.GetUserId();
|
||||
DateOnly startDate = request.StartDate ?? DateOnly.FromDateTime(DateTime.UtcNow.Date.AddMonths(-1));
|
||||
DateOnly endDate = request.EndDate ?? DateOnly.FromDateTime(DateTime.UtcNow.Date.AddMonths(3));
|
||||
|
||||
if (request.WorkspaceId.HasValue &&
|
||||
!await accessScopeService.CanAccessWorkspaceAsync(User, request.WorkspaceId.Value, ct))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
IQueryable<CalendarSource> visibleSources = dbContext.CalendarSources
|
||||
.Where(source => source.IsEnabled);
|
||||
|
||||
if (request.WorkspaceId.HasValue)
|
||||
{
|
||||
Guid? organizationId = await dbContext.Workspaces
|
||||
.Where(workspace => workspace.Id == request.WorkspaceId.Value)
|
||||
.Select(workspace => (Guid?)workspace.OrganizationId)
|
||||
.SingleOrDefaultAsync(ct);
|
||||
|
||||
if (!organizationId.HasValue)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
visibleSources = visibleSources.Where(source =>
|
||||
source.Scope == CalendarSourceScopes.Organization && source.OrganizationId == organizationId ||
|
||||
source.Scope == CalendarSourceScopes.Workspace && source.WorkspaceId == request.WorkspaceId ||
|
||||
source.Scope == CalendarSourceScopes.User && source.UserId == currentUserId);
|
||||
}
|
||||
else
|
||||
{
|
||||
IReadOnlyCollection<Guid> workspaceIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
|
||||
Guid[] organizationIds = await dbContext.Workspaces
|
||||
.Where(workspace => workspaceIds.Contains(workspace.Id))
|
||||
.Select(workspace => workspace.OrganizationId)
|
||||
.Distinct()
|
||||
.ToArrayAsync(ct);
|
||||
|
||||
visibleSources = visibleSources.Where(source =>
|
||||
source.Scope == CalendarSourceScopes.Organization && source.OrganizationId.HasValue && organizationIds.Contains(source.OrganizationId.Value) ||
|
||||
source.Scope == CalendarSourceScopes.Workspace && source.WorkspaceId.HasValue && workspaceIds.Contains(source.WorkspaceId.Value) ||
|
||||
source.Scope == CalendarSourceScopes.User && source.UserId == currentUserId);
|
||||
}
|
||||
|
||||
Guid[] sourceIds = await visibleSources
|
||||
.Select(source => source.Id)
|
||||
.ToArrayAsync(ct);
|
||||
|
||||
CalendarEventDto[] events = await dbContext.CalendarEvents
|
||||
.Where(calendarEvent => sourceIds.Contains(calendarEvent.CalendarSourceId))
|
||||
.Where(calendarEvent => calendarEvent.StartDate <= endDate && calendarEvent.EndDate >= startDate)
|
||||
.OrderBy(calendarEvent => calendarEvent.StartDate)
|
||||
.ThenBy(calendarEvent => calendarEvent.Title)
|
||||
.Select(calendarEvent => new CalendarEventDto(
|
||||
calendarEvent.Id,
|
||||
calendarEvent.CalendarSourceId,
|
||||
calendarEvent.SourceEventUid,
|
||||
calendarEvent.Title,
|
||||
calendarEvent.Description,
|
||||
calendarEvent.IsAllDay,
|
||||
calendarEvent.IsFloatingTime,
|
||||
calendarEvent.StartDate,
|
||||
calendarEvent.EndDate,
|
||||
calendarEvent.StartLocalDateTime,
|
||||
calendarEvent.EndLocalDateTime,
|
||||
calendarEvent.StartUtc,
|
||||
calendarEvent.EndUtc,
|
||||
calendarEvent.TimeZoneId,
|
||||
calendarEvent.RecurrenceId,
|
||||
calendarEvent.Location,
|
||||
calendarEvent.SourceUrl,
|
||||
calendarEvent.SourceLastModifiedAt,
|
||||
calendarEvent.ImportedAt))
|
||||
.ToArrayAsync(ct);
|
||||
|
||||
await SendOkAsync(events, ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Infrastructure.Security;
|
||||
using Socialize.Api.Modules.CalendarIntegrations.Data;
|
||||
using Socialize.Api.Modules.CalendarIntegrations.Services;
|
||||
|
||||
namespace Socialize.Api.Modules.CalendarIntegrations.Handlers;
|
||||
|
||||
public record ListCalendarSourcesRequest(Guid? WorkspaceId);
|
||||
|
||||
public class ListCalendarSourcesHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService)
|
||||
: Endpoint<ListCalendarSourcesRequest, IReadOnlyCollection<CalendarSourceDto>>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/calendar-integrations/sources");
|
||||
Options(o => o.WithTags("Calendar Integrations"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(ListCalendarSourcesRequest request, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
Guid currentUserId = User.GetUserId();
|
||||
List<CalendarSource> sources;
|
||||
|
||||
if (request.WorkspaceId.HasValue)
|
||||
{
|
||||
var workspace = await dbContext.Workspaces
|
||||
.Where(candidate => candidate.Id == request.WorkspaceId.Value)
|
||||
.Select(candidate => new { candidate.Id, candidate.OrganizationId })
|
||||
.SingleOrDefaultAsync(ct);
|
||||
|
||||
if (workspace is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await accessScopeService.CanAccessWorkspaceAsync(User, workspace.Id, ct))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
sources = await dbContext.CalendarSources
|
||||
.Where(source =>
|
||||
source.Scope == CalendarSourceScopes.Organization && source.OrganizationId == workspace.OrganizationId ||
|
||||
source.Scope == CalendarSourceScopes.Workspace && source.WorkspaceId == workspace.Id ||
|
||||
source.Scope == CalendarSourceScopes.User && source.UserId == currentUserId)
|
||||
.OrderBy(source => source.Scope)
|
||||
.ThenBy(source => source.DisplayTitle)
|
||||
.ToListAsync(ct);
|
||||
|
||||
await SendOkAsync(
|
||||
sources
|
||||
.Select(source => CalendarSourceDto.FromSource(
|
||||
source,
|
||||
CalendarSourceRules.IsInheritedOrganizationSource(source, workspace.OrganizationId)))
|
||||
.ToArray(),
|
||||
ct);
|
||||
return;
|
||||
}
|
||||
|
||||
sources = await dbContext.CalendarSources
|
||||
.Where(source => source.Scope == CalendarSourceScopes.User && source.UserId == currentUserId)
|
||||
.OrderBy(source => source.DisplayTitle)
|
||||
.ToListAsync(ct);
|
||||
|
||||
await SendOkAsync(
|
||||
sources.Select(source => CalendarSourceDto.FromSource(source, isReadOnly: false)).ToArray(),
|
||||
ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Infrastructure.Security;
|
||||
using Socialize.Api.Modules.CalendarIntegrations.Data;
|
||||
using Socialize.Api.Modules.CalendarIntegrations.Services;
|
||||
using Socialize.Api.Modules.Organizations.Services;
|
||||
|
||||
namespace Socialize.Api.Modules.CalendarIntegrations.Handlers;
|
||||
|
||||
public class RefreshCalendarSourceHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService,
|
||||
OrganizationAccessService organizationAccessService,
|
||||
CalendarImportSyncService syncService)
|
||||
: EndpointWithoutRequest<CalendarSourceDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/calendar-integrations/sources/{sourceId:guid}/refresh");
|
||||
Options(o => o.WithTags("Calendar Integrations"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
Guid sourceId = Route<Guid>("sourceId");
|
||||
CalendarSource? source = await dbContext.CalendarSources.SingleOrDefaultAsync(candidate => candidate.Id == sourceId, ct);
|
||||
if (source is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await CanManageExistingSourceAsync(source, User.GetUserId(), ct))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
await syncService.RefreshSourceAsync(source.Id, ct);
|
||||
await dbContext.Entry(source).ReloadAsync(ct);
|
||||
|
||||
await SendOkAsync(CalendarSourceDto.FromSource(source, isReadOnly: false), ct);
|
||||
}
|
||||
|
||||
private async Task<bool> CanManageExistingSourceAsync(
|
||||
CalendarSource source,
|
||||
Guid currentUserId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
return source.Scope switch
|
||||
{
|
||||
CalendarSourceScopes.Organization when source.OrganizationId.HasValue =>
|
||||
await organizationAccessService.HasOrganizationPermissionAsync(
|
||||
User,
|
||||
source.OrganizationId.Value,
|
||||
OrganizationPermissions.ManageConnectors,
|
||||
ct),
|
||||
CalendarSourceScopes.Workspace when source.WorkspaceId.HasValue =>
|
||||
await accessScopeService.CanManageWorkspaceAsync(User, source.WorkspaceId.Value, ct),
|
||||
CalendarSourceScopes.User => source.UserId == currentUserId,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Infrastructure.Security;
|
||||
using Socialize.Api.Modules.CalendarIntegrations.Data;
|
||||
using Socialize.Api.Modules.CalendarIntegrations.Services;
|
||||
using Socialize.Api.Modules.Organizations.Services;
|
||||
|
||||
namespace Socialize.Api.Modules.CalendarIntegrations.Handlers;
|
||||
|
||||
public class UpdateCalendarSourceHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService,
|
||||
OrganizationAccessService organizationAccessService)
|
||||
: Endpoint<UpsertCalendarSourceRequest, CalendarSourceDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Put("/api/calendar-integrations/sources/{sourceId:guid}");
|
||||
Options(o => o.WithTags("Calendar Integrations"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(UpsertCalendarSourceRequest request, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
Guid sourceId = Route<Guid>("sourceId");
|
||||
CalendarSource? source = await dbContext.CalendarSources.SingleOrDefaultAsync(candidate => candidate.Id == sourceId, ct);
|
||||
if (source is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
Guid currentUserId = User.GetUserId();
|
||||
if (!await CanManageExistingSourceAsync(source, currentUserId, ct))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (source.Scope != request.Scope.Trim() ||
|
||||
source.OrganizationId != (request.Scope == CalendarSourceScopes.Organization ? request.OrganizationId : null) ||
|
||||
source.WorkspaceId != (request.Scope == CalendarSourceScopes.Workspace ? request.WorkspaceId : null))
|
||||
{
|
||||
AddError("Calendar source scope cannot be changed.");
|
||||
await SendErrorsAsync(StatusCodes.Status409Conflict, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
source.SourceUrl = NormalizeOptional(request.SourceUrl);
|
||||
source.CatalogSourceReference = NormalizeOptional(request.CatalogSourceReference);
|
||||
source.DisplayTitle = request.DisplayTitle.Trim();
|
||||
source.Color = request.Color.Trim();
|
||||
source.Category = request.Category.Trim();
|
||||
source.IsEnabled = request.IsEnabled;
|
||||
source.InheritanceMode = source.Scope == CalendarSourceScopes.Organization
|
||||
? NormalizeOptional(request.InheritanceMode) ?? CalendarSourceInheritanceModes.Optional
|
||||
: null;
|
||||
source.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
|
||||
await SendOkAsync(CalendarSourceDto.FromSource(source, isReadOnly: false), ct);
|
||||
}
|
||||
|
||||
private async Task<bool> CanManageExistingSourceAsync(
|
||||
CalendarSource source,
|
||||
Guid currentUserId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
return source.Scope switch
|
||||
{
|
||||
CalendarSourceScopes.Organization when source.OrganizationId.HasValue =>
|
||||
await organizationAccessService.HasOrganizationPermissionAsync(
|
||||
User,
|
||||
source.OrganizationId.Value,
|
||||
OrganizationPermissions.ManageConnectors,
|
||||
ct),
|
||||
CalendarSourceScopes.Workspace when source.WorkspaceId.HasValue =>
|
||||
await accessScopeService.CanManageWorkspaceAsync(User, source.WorkspaceId.Value, ct),
|
||||
CalendarSourceScopes.User => source.UserId == currentUserId,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
private static string? NormalizeOptional(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Infrastructure.Security;
|
||||
using Socialize.Api.Modules.CalendarIntegrations.Data;
|
||||
using Socialize.Api.Modules.CalendarIntegrations.Services;
|
||||
using Socialize.Api.Modules.Identity.Data;
|
||||
|
||||
namespace Socialize.Api.Modules.CalendarIntegrations.Handlers;
|
||||
|
||||
public record UserCalendarExportFeedDto(
|
||||
bool IsEnabled,
|
||||
string? FeedUrl,
|
||||
DateTimeOffset? CreatedAt,
|
||||
DateTimeOffset? UpdatedAt,
|
||||
DateTimeOffset? RevokedAt);
|
||||
|
||||
public class GetUserCalendarExportFeedHandler(AppDbContext dbContext)
|
||||
: EndpointWithoutRequest<UserCalendarExportFeedDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/calendar-integrations/export-feed");
|
||||
Options(o => o.WithTags("Calendar Integrations"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
UserCalendarExportFeed? feed = await dbContext.UserCalendarExportFeeds
|
||||
.SingleOrDefaultAsync(candidate => candidate.UserId == User.GetUserId(), ct);
|
||||
|
||||
await SendAsync(UserCalendarExportFeedMapper.ToDto(feed, UserCalendarExportFeedMapper.BuildFeedUrl(feed)), cancellation: ct);
|
||||
}
|
||||
}
|
||||
|
||||
public class EnableUserCalendarExportFeedHandler(AppDbContext dbContext)
|
||||
: EndpointWithoutRequest<UserCalendarExportFeedDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/calendar-integrations/export-feed/enable");
|
||||
Options(o => o.WithTags("Calendar Integrations"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
Guid userId = User.GetUserId();
|
||||
string token = CalendarExportFeedTokenService.GenerateToken();
|
||||
string tokenHash = CalendarExportFeedTokenService.HashToken(token);
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
|
||||
UserCalendarExportFeed? feed = await dbContext.UserCalendarExportFeeds
|
||||
.SingleOrDefaultAsync(candidate => candidate.UserId == userId, ct);
|
||||
|
||||
if (feed is null)
|
||||
{
|
||||
feed = new UserCalendarExportFeed
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
Token = token,
|
||||
TokenHash = tokenHash,
|
||||
UpdatedAt = now,
|
||||
};
|
||||
dbContext.UserCalendarExportFeeds.Add(feed);
|
||||
}
|
||||
else if (feed.TokenHash is null || feed.RevokedAt.HasValue)
|
||||
{
|
||||
feed.Token = token;
|
||||
feed.TokenHash = tokenHash;
|
||||
feed.RevokedAt = null;
|
||||
feed.UpdatedAt = now;
|
||||
}
|
||||
else
|
||||
{
|
||||
token = string.Empty;
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
await SendAsync(UserCalendarExportFeedMapper.ToDto(feed, UserCalendarExportFeedMapper.BuildFeedUrl(feed, token)), cancellation: ct);
|
||||
}
|
||||
}
|
||||
|
||||
public class RegenerateUserCalendarExportFeedHandler(AppDbContext dbContext)
|
||||
: EndpointWithoutRequest<UserCalendarExportFeedDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/calendar-integrations/export-feed/regenerate");
|
||||
Options(o => o.WithTags("Calendar Integrations"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
Guid userId = User.GetUserId();
|
||||
string token = CalendarExportFeedTokenService.GenerateToken();
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
|
||||
UserCalendarExportFeed? feed = await dbContext.UserCalendarExportFeeds
|
||||
.SingleOrDefaultAsync(candidate => candidate.UserId == userId, ct);
|
||||
|
||||
if (feed is null)
|
||||
{
|
||||
feed = new UserCalendarExportFeed
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
UpdatedAt = now,
|
||||
};
|
||||
dbContext.UserCalendarExportFeeds.Add(feed);
|
||||
}
|
||||
|
||||
feed.TokenHash = CalendarExportFeedTokenService.HashToken(token);
|
||||
feed.Token = token;
|
||||
feed.RevokedAt = null;
|
||||
feed.UpdatedAt = now;
|
||||
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
await SendAsync(UserCalendarExportFeedMapper.ToDto(feed, UserCalendarExportFeedMapper.BuildFeedUrl(feed, token)), cancellation: ct);
|
||||
}
|
||||
}
|
||||
|
||||
public class RevokeUserCalendarExportFeedHandler(AppDbContext dbContext)
|
||||
: EndpointWithoutRequest<UserCalendarExportFeedDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Delete("/api/calendar-integrations/export-feed");
|
||||
Options(o => o.WithTags("Calendar Integrations"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
UserCalendarExportFeed? feed = await dbContext.UserCalendarExportFeeds
|
||||
.SingleOrDefaultAsync(candidate => candidate.UserId == User.GetUserId(), ct);
|
||||
|
||||
if (feed is not null)
|
||||
{
|
||||
feed.TokenHash = null;
|
||||
feed.Token = null;
|
||||
feed.RevokedAt = DateTimeOffset.UtcNow;
|
||||
feed.UpdatedAt = feed.RevokedAt.Value;
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
await SendAsync(UserCalendarExportFeedMapper.ToDto(feed, null), cancellation: ct);
|
||||
}
|
||||
}
|
||||
|
||||
public class GetUserCalendarExportFeedIcsHandler(
|
||||
AppDbContext dbContext,
|
||||
CalendarExportFeedService feedService)
|
||||
: EndpointWithoutRequest
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
AllowAnonymous();
|
||||
Get("/api/calendar-integrations/export-feed/{token}.ics");
|
||||
Options(o => o.WithTags("Calendar Integrations"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
string? token = Route<string?>("token");
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
string tokenHash = CalendarExportFeedTokenService.HashToken(token);
|
||||
|
||||
UserCalendarExportFeed? feed = await dbContext.UserCalendarExportFeeds
|
||||
.SingleOrDefaultAsync(candidate =>
|
||||
candidate.TokenHash == tokenHash &&
|
||||
!candidate.RevokedAt.HasValue,
|
||||
ct);
|
||||
|
||||
if (feed is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
User? user = await dbContext.Users.SingleOrDefaultAsync(candidate => candidate.Id == feed.UserId, ct);
|
||||
if (user is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
string appBaseUrl = $"{HttpContext.Request.Scheme}://{HttpContext.Request.Host}";
|
||||
string ics = await feedService.BuildUserFeedAsync(feed.UserId, user.Email, appBaseUrl, ct);
|
||||
|
||||
HttpContext.Response.ContentType = "text/calendar; charset=utf-8";
|
||||
await HttpContext.Response.WriteAsync(ics, ct);
|
||||
}
|
||||
}
|
||||
|
||||
file static class UserCalendarExportFeedMapper
|
||||
{
|
||||
public static UserCalendarExportFeedDto ToDto(UserCalendarExportFeed? feed, string? feedUrl)
|
||||
{
|
||||
return new UserCalendarExportFeedDto(
|
||||
feed?.TokenHash is not null && !feed.RevokedAt.HasValue,
|
||||
feedUrl,
|
||||
feed?.CreatedAt,
|
||||
feed?.UpdatedAt,
|
||||
feed?.RevokedAt);
|
||||
}
|
||||
|
||||
public static string? BuildFeedUrl(UserCalendarExportFeed? feed, string? token = null)
|
||||
{
|
||||
if (feed?.TokenHash is null || feed.RevokedAt.HasValue)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string effectiveToken = string.IsNullOrWhiteSpace(token) ? feed.Token ?? string.Empty : token;
|
||||
return string.IsNullOrWhiteSpace(effectiveToken)
|
||||
? null
|
||||
: $"/api/calendar-integrations/export-feed/{effectiveToken}.ics";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user