Files
social-media/backend/src/Socialize.Api/Modules/CalendarIntegrations/Services/CalendarImportSyncService.cs
Jonathan Bourdon b66c10b681
Some checks failed
Backend CI/CD / build_and_deploy (push) Has been cancelled
Frontend CI/CD / build_and_deploy (push) Has been cancelled
Add calendar integrations and collaboration updates
2026-05-05 15:25:53 -04:00

314 lines
10 KiB
C#

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