refactor: GoogleCalendarService implements ICalendarService, uses CalendarEventBuilder (stub)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
8c765e4d49
commit
839abe6cf4
3 changed files with 39 additions and 109 deletions
|
|
@ -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)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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 =>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -53,24 +44,23 @@ public class GoogleCalendarService(IHttpContextAccessor httpContextAccessor, ICo
|
||||||
request.TimeMaxDateTimeOffset = endDate;
|
request.TimeMaxDateTimeOffset = endDate;
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
@ -82,8 +72,7 @@ public class GoogleCalendarService(IHttpContextAccessor httpContextAccessor, ICo
|
||||||
foreach (var shiftWithTransport in shifts)
|
foreach (var shiftWithTransport in shifts)
|
||||||
{
|
{
|
||||||
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,118 +83,59 @@ 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);
|
||||||
|
|
||||||
return new CalendarService(new BaseClientService.Initializer
|
return new CalendarService(new BaseClientService.Initializer
|
||||||
{
|
{
|
||||||
HttpClientInitializer = credential,
|
HttpClientInitializer = credential,
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue