From 839abe6cf48ee6b6759c17c7fb7f738f932269d6 Mon Sep 17 00:00:00 2001 From: Claudio Schaad Date: Fri, 10 Apr 2026 22:44:32 +0200 Subject: [PATCH] refactor: GoogleCalendarService implements ICalendarService, uses CalendarEventBuilder (stub) Co-Authored-By: Claude Sonnet 4.6 --- Server/Controllers/ShiftController.cs | 2 +- Server/Program.cs | 2 +- Services/GoogleCalendarService.cs | 144 +++++++------------------- 3 files changed, 39 insertions(+), 109 deletions(-) diff --git a/Server/Controllers/ShiftController.cs b/Server/Controllers/ShiftController.cs index b22ad76..edf6e2b 100644 --- a/Server/Controllers/ShiftController.cs +++ b/Server/Controllers/ShiftController.cs @@ -160,7 +160,7 @@ namespace ShiftScheduler.Server.Controllers try { var calendars = await _googleCalendarService.GetCalendarsAsync(); - return Ok(calendars.Select(c => new { Id = c.Id, Summary = c.Summary })); + return Ok(calendars); } catch (Exception ex) { diff --git a/Server/Program.cs b/Server/Program.cs index 8dfd977..6293348 100644 --- a/Server/Program.cs +++ b/Server/Program.cs @@ -29,7 +29,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHttpContextAccessor(); -builder.Services.AddScoped(); +builder.Services.AddScoped(); // Configure authentication builder.Services.AddAuthentication(options => diff --git a/Services/GoogleCalendarService.cs b/Services/GoogleCalendarService.cs index 1a5d90d..0a3f17a 100644 --- a/Services/GoogleCalendarService.cs +++ b/Services/GoogleCalendarService.cs @@ -8,41 +8,32 @@ using ShiftScheduler.Shared; namespace ShiftScheduler.Services; -public interface IOldGoogleCalendarService -{ - Task> GetCalendarsAsync(); - Task SyncShiftsToCalendarAsync(string calendarId, List shifts); -} - public class GoogleCalendarService(IHttpContextAccessor httpContextAccessor, IConfigurationService configurationService) - : IOldGoogleCalendarService + : IGoogleCalendarService { - public async Task> GetCalendarsAsync() + public async Task> GetCalendarsAsync() { var service = await CreateCalendarServiceAsync(); var request = service.CalendarList.List(); - var response = await ExecuteWithRetryAsync(async () => await request.ExecuteAsync()); - - return response.Items?.Where(c => c.AccessRole == "owner" || c.AccessRole == "writer").ToList() ?? []; + var response = await CalendarEventBuilder.ExecuteWithRetryAsync(async () => await request.ExecuteAsync()); + + return response.Items? + .Where(c => c.AccessRole == "owner" || c.AccessRole == "writer") + .Select(c => new CalendarInfo { Id = c.Id, Summary = c.Summary }) + .ToList() ?? []; } public async Task SyncShiftsToCalendarAsync(string calendarId, List shifts) { var service = await CreateCalendarServiceAsync(); - - // Get date range for deletion + if (shifts.Count == 0) return; - + var minDate = shifts.Min(s => s.Date).Date; var maxDate = shifts.Max(s => s.Date).Date.AddDays(1); - - // Delete existing shift events in the date range + await DeleteExistingShiftEventsAsync(service, calendarId, minDate, maxDate); - - // Add a small delay to avoid rate limiting await Task.Delay(500); - - // Create new events for each shift with rate limiting protection await CreateShiftEventsWithRateLimitingAsync(service, calendarId, shifts); } @@ -53,24 +44,23 @@ public class GoogleCalendarService(IHttpContextAccessor httpContextAccessor, ICo request.TimeMaxDateTimeOffset = endDate; request.SingleEvents = true; request.OrderBy = EventsResource.ListRequest.OrderByEnum.StartTime; - - var response = await ExecuteWithRetryAsync(async () => await request.ExecuteAsync()); - + + var response = await CalendarEventBuilder.ExecuteWithRetryAsync(async () => await request.ExecuteAsync()); + if (response.Items != null) { - var shiftsToDelete = response.Items.Where(e => - e.ExtendedProperties?.Private__?.ContainsKey("shiftSchedulerEvent") == true).ToList(); - - // Delete events with rate limiting protection + var shiftsToDelete = response.Items + .Where(e => e.ExtendedProperties?.Private__?.ContainsKey("shiftSchedulerEvent") == true) + .ToList(); + foreach (var eventToDelete in shiftsToDelete) { - await ExecuteWithRetryAsync(async () => + await CalendarEventBuilder.ExecuteWithRetryAsync(async () => { await service.Events.Delete(calendarId, eventToDelete.Id).ExecuteAsync(); - return true; // Return something for the generic method + return true; }); - - // Small delay between deletions to avoid rate limiting + if (shiftsToDelete.Count > 1) await Task.Delay(200); } @@ -82,8 +72,7 @@ public class GoogleCalendarService(IHttpContextAccessor httpContextAccessor, ICo foreach (var shiftWithTransport in shifts) { await CreateShiftEventsAsync(service, calendarId, shiftWithTransport); - - // Add delay between shift processing to avoid rate limiting + if (shifts.Count > 1) await Task.Delay(300); } @@ -94,118 +83,59 @@ public class GoogleCalendarService(IHttpContextAccessor httpContextAccessor, ICo var shift = shiftWithTransport.Shift; var date = shiftWithTransport.Date; - // Skip shifts with no time information if (string.IsNullOrEmpty(shift.MorningTime) && string.IsNullOrEmpty(shift.AfternoonTime)) return; - // Create morning event if it exists if (!string.IsNullOrEmpty(shift.MorningTime)) { var (startTime, endTime) = configurationService.GetZurichTime(DateOnly.FromDateTime(date), shift.MorningTime); - var morningEvent = CreateEventFromShift(shift, startTime, endTime, shiftWithTransport.MorningTransport); - await ExecuteWithRetryAsync(async () => await service.Events.Insert(morningEvent, calendarId).ExecuteAsync()); + var eventData = CalendarEventBuilder.BuildEventContent(shift, shiftWithTransport.MorningTransport); + var googleEvent = CreateGoogleEvent(eventData, startTime, endTime); + await CalendarEventBuilder.ExecuteWithRetryAsync(async () => await service.Events.Insert(googleEvent, calendarId).ExecuteAsync()); } - // Create afternoon event if it exists if (!string.IsNullOrEmpty(shift.AfternoonTime)) { var (startTime, endTime) = configurationService.GetZurichTime(DateOnly.FromDateTime(date), shift.AfternoonTime); - var afternoonEvent = CreateEventFromShift(shift, startTime, endTime, shiftWithTransport.AfternoonTransport); - await ExecuteWithRetryAsync(async () => await service.Events.Insert(afternoonEvent, calendarId).ExecuteAsync()); + var eventData = CalendarEventBuilder.BuildEventContent(shift, shiftWithTransport.AfternoonTransport); + var googleEvent = CreateGoogleEvent(eventData, startTime, endTime); + await CalendarEventBuilder.ExecuteWithRetryAsync(async () => await service.Events.Insert(googleEvent, calendarId).ExecuteAsync()); } } - private Event CreateEventFromShift(Shift shift, DateTimeOffset startTime, DateTimeOffset endTime, TransportConnection? transport) + private static Event CreateGoogleEvent(CalendarEventData eventData, DateTimeOffset startTime, DateTimeOffset endTime) { - var summary = $"{shift.Name}"; - var description = ""; - - if (transport != null) - { - var transportInfo = FormatTransportInfo(transport); - description = $"Transport: {transportInfo}"; - } - return new Event { - Summary = summary, - Description = description, - Start = new EventDateTime - { - DateTimeDateTimeOffset = startTime - }, - End = new EventDateTime - { - DateTimeDateTimeOffset = endTime - }, + Summary = eventData.Summary, + Description = eventData.Description, + Start = new EventDateTime { DateTimeDateTimeOffset = startTime }, + End = new EventDateTime { DateTimeDateTimeOffset = endTime }, ExtendedProperties = new Event.ExtendedPropertiesData { Private__ = new Dictionary { ["shiftSchedulerEvent"] = "true", - ["shiftName"] = shift.Name + ["shiftName"] = eventData.Summary } } }; } - private static string FormatTransportInfo(TransportConnection transport) - { - var departure = transport.DepartureTime.ToString("HH:mm"); - var arrival = transport.ArrivalTime.ToString("HH:mm"); - return $"{transport.Platform}: {departure} → {arrival} ({transport.Duration.TotalMinutes} Minutes)"; - } - private async Task CreateCalendarServiceAsync() { var httpContext = httpContextAccessor.HttpContext ?? throw new InvalidOperationException("No HTTP context available"); - + var accessToken = await httpContext.GetTokenAsync("access_token"); if (string.IsNullOrEmpty(accessToken)) - { throw new UnauthorizedAccessException("No access token available"); - } var credential = GoogleCredential.FromAccessToken(accessToken); - + return new CalendarService(new BaseClientService.Initializer { HttpClientInitializer = credential, ApplicationName = "ShiftScheduler" }); } - - private static async Task ExecuteWithRetryAsync(Func> operation, int maxRetries = 3) - { - var delay = TimeSpan.FromSeconds(1); - - for (var attempt = 0; attempt <= maxRetries; attempt++) - { - try - { - return await operation(); - } - catch (HttpRequestException ex) when (ex.Message.Contains("429") && attempt < maxRetries) - { - // Rate limit exceeded (HTTP 429), wait with exponential backoff - await Task.Delay(delay); - delay = TimeSpan.FromMilliseconds(delay.TotalMilliseconds * 2); // Exponential backoff - } - catch (Exception ex) when (ex.Message.Contains("Rate Limit") && attempt < maxRetries) - { - // Another form of rate limit error - await Task.Delay(delay); - delay = TimeSpan.FromMilliseconds(delay.TotalMilliseconds * 2); // Exponential backoff - } - catch (Exception ex) when (ex.Message.Contains("Forbidden") && ex.Message.Contains("quota") && attempt < maxRetries) - { - // Quota exceeded error - await Task.Delay(delay); - delay = TimeSpan.FromMilliseconds(delay.TotalMilliseconds * 2); // Exponential backoff - } - } - - // If we get here, all retries failed, execute one more time to get the actual exception - return await operation(); - } -} \ No newline at end of file +}