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 { 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 { 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 { 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 { 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("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"; } }