refactor: GoogleCalendarService implements ICalendarService, uses CalendarEventBuilder (stub)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Claudio Schaad 2026-04-10 22:44:32 +02:00
parent 8c765e4d49
commit 839abe6cf4
3 changed files with 39 additions and 109 deletions

View file

@ -160,7 +160,7 @@ namespace ShiftScheduler.Server.Controllers
try try
{ {
var calendars = await _googleCalendarService.GetCalendarsAsync(); var calendars = await _googleCalendarService.GetCalendarsAsync();
return Ok(calendars.Select(c => new { Id = c.Id, Summary = c.Summary })); return Ok(calendars);
} }
catch (Exception ex) catch (Exception ex)
{ {

View file

@ -29,7 +29,7 @@ builder.Services.AddSingleton<PdfExportService>();
builder.Services.AddSingleton<ITransportApiService, TransportApiService>(); builder.Services.AddSingleton<ITransportApiService, TransportApiService>();
builder.Services.AddSingleton<ITransportService, TransportService>(); builder.Services.AddSingleton<ITransportService, TransportService>();
builder.Services.AddHttpContextAccessor(); builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<IOldGoogleCalendarService, GoogleCalendarService>(); builder.Services.AddScoped<IGoogleCalendarService, GoogleCalendarService>();
// Configure authentication // Configure authentication
builder.Services.AddAuthentication(options => builder.Services.AddAuthentication(options =>

View file

@ -8,41 +8,32 @@ using ShiftScheduler.Shared;
namespace ShiftScheduler.Services; namespace ShiftScheduler.Services;
public interface IOldGoogleCalendarService
{
Task<List<CalendarListEntry>> GetCalendarsAsync();
Task SyncShiftsToCalendarAsync(string calendarId, List<ShiftWithTransport> shifts);
}
public class GoogleCalendarService(IHttpContextAccessor httpContextAccessor, IConfigurationService configurationService) public class GoogleCalendarService(IHttpContextAccessor httpContextAccessor, IConfigurationService configurationService)
: IOldGoogleCalendarService : IGoogleCalendarService
{ {
public async Task<List<CalendarListEntry>> GetCalendarsAsync() public async Task<List<CalendarInfo>> GetCalendarsAsync()
{ {
var service = await CreateCalendarServiceAsync(); var service = await CreateCalendarServiceAsync();
var request = service.CalendarList.List(); var request = service.CalendarList.List();
var response = await ExecuteWithRetryAsync(async () => await request.ExecuteAsync()); var response = await CalendarEventBuilder.ExecuteWithRetryAsync(async () => await request.ExecuteAsync());
return response.Items?.Where(c => c.AccessRole == "owner" || c.AccessRole == "writer").ToList() ?? []; 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<ShiftWithTransport> shifts) public async Task SyncShiftsToCalendarAsync(string calendarId, List<ShiftWithTransport> shifts)
{ {
var service = await CreateCalendarServiceAsync(); var service = await CreateCalendarServiceAsync();
// Get date range for deletion
if (shifts.Count == 0) return; if (shifts.Count == 0) return;
var minDate = shifts.Min(s => s.Date).Date; var minDate = shifts.Min(s => s.Date).Date;
var maxDate = shifts.Max(s => s.Date).Date.AddDays(1); var maxDate = shifts.Max(s => s.Date).Date.AddDays(1);
// Delete existing shift events in the date range
await DeleteExistingShiftEventsAsync(service, calendarId, minDate, maxDate); await DeleteExistingShiftEventsAsync(service, calendarId, minDate, maxDate);
// Add a small delay to avoid rate limiting
await Task.Delay(500); await Task.Delay(500);
// Create new events for each shift with rate limiting protection
await CreateShiftEventsWithRateLimitingAsync(service, calendarId, shifts); await CreateShiftEventsWithRateLimitingAsync(service, calendarId, shifts);
} }
@ -54,23 +45,22 @@ public class GoogleCalendarService(IHttpContextAccessor httpContextAccessor, ICo
request.SingleEvents = true; request.SingleEvents = true;
request.OrderBy = EventsResource.ListRequest.OrderByEnum.StartTime; 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) if (response.Items != null)
{ {
var shiftsToDelete = response.Items.Where(e => var shiftsToDelete = response.Items
e.ExtendedProperties?.Private__?.ContainsKey("shiftSchedulerEvent") == true).ToList(); .Where(e => e.ExtendedProperties?.Private__?.ContainsKey("shiftSchedulerEvent") == true)
.ToList();
// Delete events with rate limiting protection
foreach (var eventToDelete in shiftsToDelete) foreach (var eventToDelete in shiftsToDelete)
{ {
await ExecuteWithRetryAsync(async () => await CalendarEventBuilder.ExecuteWithRetryAsync(async () =>
{ {
await service.Events.Delete(calendarId, eventToDelete.Id).ExecuteAsync(); 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) if (shiftsToDelete.Count > 1)
await Task.Delay(200); await Task.Delay(200);
} }
@ -83,7 +73,6 @@ public class GoogleCalendarService(IHttpContextAccessor httpContextAccessor, ICo
{ {
await CreateShiftEventsAsync(service, calendarId, shiftWithTransport); await CreateShiftEventsAsync(service, calendarId, shiftWithTransport);
// Add delay between shift processing to avoid rate limiting
if (shifts.Count > 1) if (shifts.Count > 1)
await Task.Delay(300); await Task.Delay(300);
} }
@ -94,77 +83,52 @@ public class GoogleCalendarService(IHttpContextAccessor httpContextAccessor, ICo
var shift = shiftWithTransport.Shift; var shift = shiftWithTransport.Shift;
var date = shiftWithTransport.Date; var date = shiftWithTransport.Date;
// Skip shifts with no time information
if (string.IsNullOrEmpty(shift.MorningTime) && string.IsNullOrEmpty(shift.AfternoonTime)) if (string.IsNullOrEmpty(shift.MorningTime) && string.IsNullOrEmpty(shift.AfternoonTime))
return; return;
// Create morning event if it exists
if (!string.IsNullOrEmpty(shift.MorningTime)) if (!string.IsNullOrEmpty(shift.MorningTime))
{ {
var (startTime, endTime) = configurationService.GetZurichTime(DateOnly.FromDateTime(date), shift.MorningTime); var (startTime, endTime) = configurationService.GetZurichTime(DateOnly.FromDateTime(date), shift.MorningTime);
var morningEvent = CreateEventFromShift(shift, startTime, endTime, shiftWithTransport.MorningTransport); var eventData = CalendarEventBuilder.BuildEventContent(shift, shiftWithTransport.MorningTransport);
await ExecuteWithRetryAsync(async () => await service.Events.Insert(morningEvent, calendarId).ExecuteAsync()); 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)) if (!string.IsNullOrEmpty(shift.AfternoonTime))
{ {
var (startTime, endTime) = configurationService.GetZurichTime(DateOnly.FromDateTime(date), shift.AfternoonTime); var (startTime, endTime) = configurationService.GetZurichTime(DateOnly.FromDateTime(date), shift.AfternoonTime);
var afternoonEvent = CreateEventFromShift(shift, startTime, endTime, shiftWithTransport.AfternoonTransport); var eventData = CalendarEventBuilder.BuildEventContent(shift, shiftWithTransport.AfternoonTransport);
await ExecuteWithRetryAsync(async () => await service.Events.Insert(afternoonEvent, calendarId).ExecuteAsync()); 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 return new Event
{ {
Summary = summary, Summary = eventData.Summary,
Description = description, Description = eventData.Description,
Start = new EventDateTime Start = new EventDateTime { DateTimeDateTimeOffset = startTime },
{ End = new EventDateTime { DateTimeDateTimeOffset = endTime },
DateTimeDateTimeOffset = startTime
},
End = new EventDateTime
{
DateTimeDateTimeOffset = endTime
},
ExtendedProperties = new Event.ExtendedPropertiesData ExtendedProperties = new Event.ExtendedPropertiesData
{ {
Private__ = new Dictionary<string, string> Private__ = new Dictionary<string, string>
{ {
["shiftSchedulerEvent"] = "true", ["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<CalendarService> CreateCalendarServiceAsync() private async Task<CalendarService> CreateCalendarServiceAsync()
{ {
var httpContext = httpContextAccessor.HttpContext ?? throw new InvalidOperationException("No HTTP context available"); var httpContext = httpContextAccessor.HttpContext ?? throw new InvalidOperationException("No HTTP context available");
var accessToken = await httpContext.GetTokenAsync("access_token"); var accessToken = await httpContext.GetTokenAsync("access_token");
if (string.IsNullOrEmpty(accessToken)) if (string.IsNullOrEmpty(accessToken))
{
throw new UnauthorizedAccessException("No access token available"); throw new UnauthorizedAccessException("No access token available");
}
var credential = GoogleCredential.FromAccessToken(accessToken); var credential = GoogleCredential.FromAccessToken(accessToken);
@ -174,38 +138,4 @@ public class GoogleCalendarService(IHttpContextAccessor httpContextAccessor, ICo
ApplicationName = "ShiftScheduler" ApplicationName = "ShiftScheduler"
}); });
} }
private static async Task<T> ExecuteWithRetryAsync<T>(Func<Task<T>> 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();
}
} }