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 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 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> 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> GetNagerEventsAsync( HttpClient httpClient, string sourceUrl, string countryCode, DateOnly rangeStart, DateOnly rangeEnd, CancellationToken ct) { List 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( 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 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); }