RSVP options.

This commit is contained in:
Burak Kaan Köse
2026-01-03 19:33:36 +01:00
parent a64627e7d6
commit 9877656eea
28 changed files with 968 additions and 115 deletions
@@ -349,10 +349,10 @@ public class GmailChangeProcessor : DefaultChangeProcessor, IGmailChangeProcesso
{
return status switch
{
"confirmed" => CalendarItemStatus.Confirmed,
"confirmed" => CalendarItemStatus.Accepted,
"tentative" => CalendarItemStatus.Tentative,
"cancelled" => CalendarItemStatus.Cancelled,
_ => CalendarItemStatus.Confirmed
_ => CalendarItemStatus.Accepted
};
}
@@ -152,7 +152,7 @@ public class OutlookChangeProcessor(IDatabaseService databaseService,
break;
case ResponseType.Accepted:
case ResponseType.Organizer:
savingItem.Status = CalendarItemStatus.Confirmed;
savingItem.Status = CalendarItemStatus.Accepted;
break;
case ResponseType.Declined:
savingItem.Status = CalendarItemStatus.Cancelled;
@@ -164,7 +164,7 @@ public class OutlookChangeProcessor(IDatabaseService databaseService,
}
else
{
savingItem.Status = CalendarItemStatus.Confirmed;
savingItem.Status = CalendarItemStatus.Accepted;
}
// Prepare attendees list
@@ -0,0 +1,39 @@
using CommunityToolkit.Mvvm.Messaging;
using Wino.Core.Domain.Entities.Calendar;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Models.Requests;
using Wino.Messaging.Client.Calendar;
namespace Wino.Core.Requests.Calendar;
/// <summary>
/// Request to accept a calendar event invitation on the server.
/// The calendar item status should be updated locally before queuing this request.
/// </summary>
public record AcceptEventRequest(CalendarItem Item, string ResponseMessage = null) : CalendarRequestBase(Item)
{
private readonly CalendarItemStatus _previousStatus = Item.Status;
public override CalendarSynchronizerOperation Operation => CalendarSynchronizerOperation.AcceptEvent;
/// <summary>
/// After successful acceptance, we need to resync to get updated status.
/// </summary>
public override int ResynchronizationDelay => 2000;
public override void ApplyUIChanges()
{
// Update the item status locally
Item.Status = CalendarItemStatus.Accepted;
// Notify UI that the event status was updated
WeakReferenceMessenger.Default.Send(new CalendarItemUpdated(Item));
}
public override void RevertUIChanges()
{
// If acceptance fails, revert to the previous status
Item.Status = _previousStatus;
WeakReferenceMessenger.Default.Send(new CalendarItemUpdated(Item));
}
}
@@ -0,0 +1,39 @@
using CommunityToolkit.Mvvm.Messaging;
using Wino.Core.Domain.Entities.Calendar;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Models.Requests;
using Wino.Messaging.Client.Calendar;
namespace Wino.Core.Requests.Calendar;
/// <summary>
/// Request to decline a calendar event invitation on the server.
/// The calendar item status should be updated locally before queuing this request.
/// </summary>
public record DeclineEventRequest(CalendarItem Item, string ResponseMessage = null) : CalendarRequestBase(Item)
{
private readonly CalendarItemStatus _previousStatus = Item.Status;
public override CalendarSynchronizerOperation Operation => CalendarSynchronizerOperation.DeclineEvent;
/// <summary>
/// After successful decline, we need to resync to get updated status.
/// </summary>
public override int ResynchronizationDelay => 2000;
public override void ApplyUIChanges()
{
// Update the item status locally
Item.Status = CalendarItemStatus.Cancelled;
// Notify UI that the event status was updated
WeakReferenceMessenger.Default.Send(new CalendarItemUpdated(Item));
}
public override void RevertUIChanges()
{
// If decline fails, revert to the previous status
Item.Status = _previousStatus;
WeakReferenceMessenger.Default.Send(new CalendarItemUpdated(Item));
}
}
@@ -0,0 +1,38 @@
using CommunityToolkit.Mvvm.Messaging;
using Wino.Core.Domain.Entities.Calendar;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Models.Requests;
using Wino.Messaging.Client.Calendar;
namespace Wino.Core.Requests.Calendar;
/// <summary>
/// Outlook-specific request to decline a calendar event invitation.
/// In Outlook, declined events are removed from the calendar by the API after synchronization,
/// so this request sends a delete notification to remove the event from the UI.
/// </summary>
public record OutlookDeclineEventRequest(CalendarItem Item, string ResponseMessage = null) : CalendarRequestBase(Item)
{
private readonly CalendarItemStatus _previousStatus = Item.Status;
public override CalendarSynchronizerOperation Operation => CalendarSynchronizerOperation.DeclineEvent;
/// <summary>
/// After successful decline, we need to resync to confirm the event is removed.
/// </summary>
public override int ResynchronizationDelay => 2000;
public override void ApplyUIChanges()
{
// In Outlook, declined events are deleted from the calendar after sync
// Send deleted message to remove from UI immediately
WeakReferenceMessenger.Default.Send(new CalendarItemDeleted(Item));
}
public override void RevertUIChanges()
{
// If decline fails, restore the previous status and re-add the event
Item.Status = _previousStatus;
WeakReferenceMessenger.Default.Send(new CalendarItemAdded(Item));
}
}
@@ -0,0 +1,39 @@
using CommunityToolkit.Mvvm.Messaging;
using Wino.Core.Domain.Entities.Calendar;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Models.Requests;
using Wino.Messaging.Client.Calendar;
namespace Wino.Core.Requests.Calendar;
/// <summary>
/// Request to tentatively accept a calendar event invitation on the server.
/// The calendar item status should be updated locally before queuing this request.
/// </summary>
public record TentativeEventRequest(CalendarItem Item, string ResponseMessage = null) : CalendarRequestBase(Item)
{
private readonly CalendarItemStatus _previousStatus = Item.Status;
public override CalendarSynchronizerOperation Operation => CalendarSynchronizerOperation.TentativeEvent;
/// <summary>
/// After successful tentative acceptance, we need to resync to get updated status.
/// </summary>
public override int ResynchronizationDelay => 2000;
public override void ApplyUIChanges()
{
// Update the item status locally
Item.Status = CalendarItemStatus.Tentative;
// Notify UI that the event status was updated
WeakReferenceMessenger.Default.Send(new CalendarItemUpdated(Item));
}
public override void RevertUIChanges()
{
// If tentative acceptance fails, revert to the previous status
Item.Status = _previousStatus;
WeakReferenceMessenger.Default.Send(new CalendarItemUpdated(Item));
}
}
@@ -5,6 +5,7 @@ using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Messaging;
using Serilog;
using Wino.Core.Domain;
using Wino.Core.Domain.Entities.Calendar;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Exceptions;
using Wino.Core.Domain.Interfaces;
@@ -141,6 +142,9 @@ public class WinoRequestDelegator : IWinoRequestDelegator
{
CalendarSynchronizerOperation.CreateEvent => new CreateCalendarEventRequest(calendarPreparationRequest.CalendarItem, calendarPreparationRequest.Attendees),
CalendarSynchronizerOperation.DeleteEvent => new DeleteCalendarEventRequest(calendarPreparationRequest.CalendarItem),
CalendarSynchronizerOperation.AcceptEvent => new AcceptEventRequest(calendarPreparationRequest.CalendarItem, calendarPreparationRequest.ResponseMessage),
CalendarSynchronizerOperation.DeclineEvent => CreateDeclineRequest(calendarPreparationRequest.CalendarItem, calendarPreparationRequest.ResponseMessage),
CalendarSynchronizerOperation.TentativeEvent => new TentativeEventRequest(calendarPreparationRequest.CalendarItem, calendarPreparationRequest.ResponseMessage),
// Future support for update operations
// CalendarSynchronizerOperation.UpdateEvent => new UpdateCalendarEventRequest(calendarPreparationRequest.CalendarItem, calendarPreparationRequest.Attendees),
_ => throw new NotImplementedException($"Calendar operation {calendarPreparationRequest.Operation} is not implemented yet.")
@@ -150,6 +154,18 @@ public class WinoRequestDelegator : IWinoRequestDelegator
await QueueCalendarSynchronizationAsync(calendarPreparationRequest.CalendarItem.AssignedCalendar.AccountId);
}
private IRequestBase CreateDeclineRequest(CalendarItem calendarItem, string responseMessage)
{
// For Outlook accounts, declined events are deleted by the server after synchronization.
// Use OutlookDeclineEventRequest to handle UI removal.
if (calendarItem.AssignedCalendar?.MailAccount?.ProviderType == MailProviderType.Outlook)
{
return new OutlookDeclineEventRequest(calendarItem, responseMessage);
}
return new DeclineEventRequest(calendarItem, responseMessage);
}
private async Task QueueRequestAsync(IRequestBase request, Guid accountId)
{
// Don't trigger synchronization for individual requests - we'll trigger it once for all requests
+121 -1
View File
@@ -8,6 +8,7 @@ using System.Threading.Tasks;
using System.Web;
using CommunityToolkit.Mvvm.Messaging;
using Google;
using Google.Apis.Calendar.v3;
using Google.Apis.Calendar.v3.Data;
using Google.Apis.Gmail.v1;
using Google.Apis.Gmail.v1.Data;
@@ -1648,7 +1649,7 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
Summary = calendarItem.Title,
Description = calendarItem.Description,
Location = calendarItem.Location,
Status = calendarItem.Status == CalendarItemStatus.Confirmed ? "confirmed" : "tentative"
Status = calendarItem.Status == CalendarItemStatus.Accepted ? "confirmed" : "tentative"
};
// Set start and end time
@@ -1696,6 +1697,125 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
return [new HttpRequestBundle<IClientServiceRequest>(insertRequest, request)];
}
public override List<IRequestBundle<IClientServiceRequest>> AcceptEvent(AcceptEventRequest request)
{
var calendarItem = request.Item;
var calendar = calendarItem.AssignedCalendar;
if (calendar == null)
{
throw new InvalidOperationException("Calendar item must have an assigned calendar");
}
if (string.IsNullOrEmpty(calendarItem.RemoteEventId))
{
throw new InvalidOperationException("Cannot accept event without remote event ID");
}
// For Gmail, we need to patch the event with the user's response status
// Get the current user's email from the account
var userEmail = Account.Address;
// Create a patch event to update only the attendee response
var patchEvent = new Event();
// We need to get the event first to update the specific attendee
// However, for efficiency, we'll use the patch method with sendUpdates parameter
var patchRequest = _calendarService.Events.Patch(new Event
{
// The API will handle updating the current user's attendee status
Attendees = new List<EventAttendee>
{
new EventAttendee
{
Email = userEmail,
ResponseStatus = "accepted"
}
}
}, calendar.RemoteCalendarId, calendarItem.RemoteEventId);
// Send updates to other attendees if there's a message
patchRequest.SendUpdates = !string.IsNullOrEmpty(request.ResponseMessage)
? Google.Apis.Calendar.v3.EventsResource.PatchRequest.SendUpdatesEnum.All
: Google.Apis.Calendar.v3.EventsResource.PatchRequest.SendUpdatesEnum.None;
return [new HttpRequestBundle<IClientServiceRequest>(patchRequest, request)];
}
public override List<IRequestBundle<IClientServiceRequest>> DeclineEvent(DeclineEventRequest request)
{
var calendarItem = request.Item;
var calendar = calendarItem.AssignedCalendar;
if (calendar == null)
{
throw new InvalidOperationException("Calendar item must have an assigned calendar");
}
if (string.IsNullOrEmpty(calendarItem.RemoteEventId))
{
throw new InvalidOperationException("Cannot decline event without remote event ID");
}
var userEmail = Account.Address;
var patchRequest = _calendarService.Events.Patch(new Event
{
Attendees = new List<EventAttendee>
{
new EventAttendee
{
Email = userEmail,
ResponseStatus = "declined",
Comment = request.ResponseMessage
}
}
}, calendar.RemoteCalendarId, calendarItem.RemoteEventId);
patchRequest.SendUpdates = !string.IsNullOrEmpty(request.ResponseMessage)
? Google.Apis.Calendar.v3.EventsResource.PatchRequest.SendUpdatesEnum.All
: Google.Apis.Calendar.v3.EventsResource.PatchRequest.SendUpdatesEnum.None;
return [new HttpRequestBundle<IClientServiceRequest>(patchRequest, request)];
}
public override List<IRequestBundle<IClientServiceRequest>> TentativeEvent(TentativeEventRequest request)
{
var calendarItem = request.Item;
var calendar = calendarItem.AssignedCalendar;
if (calendar == null)
{
throw new InvalidOperationException("Calendar item must have an assigned calendar");
}
if (string.IsNullOrEmpty(calendarItem.RemoteEventId))
{
throw new InvalidOperationException("Cannot tentatively accept event without remote event ID");
}
var userEmail = Account.Address;
var patchRequest = _calendarService.Events.Patch(new Event
{
Attendees = new List<EventAttendee>
{
new EventAttendee
{
Email = userEmail,
ResponseStatus = "tentative",
Comment = request.ResponseMessage
}
}
}, calendar.RemoteCalendarId, calendarItem.RemoteEventId);
patchRequest.SendUpdates = !string.IsNullOrEmpty(request.ResponseMessage)
? Google.Apis.Calendar.v3.EventsResource.PatchRequest.SendUpdatesEnum.All
: Google.Apis.Calendar.v3.EventsResource.PatchRequest.SendUpdatesEnum.None;
return [new HttpRequestBundle<IClientServiceRequest>(patchRequest, request)];
}
#endregion
public override async Task KillSynchronizerAsync()
@@ -1680,6 +1680,9 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
foreach (var item in events)
{
// Declined events are returned as Deleted from the API.
// There is no way to distinguish unfortunately atm.
if (IsResourceDeleted(item.AdditionalData))
{
await _outlookChangeProcessor.DeleteCalendarItemAsync(item.Id, calendar.Id).ConfigureAwait(false);
@@ -1886,6 +1889,80 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
return [new HttpRequestBundle<RequestInformation>(createRequest, request)];
}
public override List<IRequestBundle<RequestInformation>> AcceptEvent(AcceptEventRequest request)
{
var calendarItem = request.Item;
var calendar = calendarItem.AssignedCalendar;
if (calendar == null)
{
throw new InvalidOperationException("Calendar item must have an assigned calendar");
}
if (string.IsNullOrEmpty(calendarItem.RemoteEventId))
{
throw new InvalidOperationException("Cannot accept event without remote event ID");
}
var acceptRequestInfo = _graphClient.Me.Calendars[calendar.RemoteCalendarId].Events[calendarItem.RemoteEventId].Accept.ToPostRequestInformation(new Microsoft.Graph.Me.Calendars.Item.Events.Item.Accept.AcceptPostRequestBody
{
Comment = request.ResponseMessage,
SendResponse = !string.IsNullOrEmpty(request.ResponseMessage)
});
return [new HttpRequestBundle<RequestInformation>(acceptRequestInfo, request)];
}
public override List<IRequestBundle<RequestInformation>> OutlookDeclineEvent(OutlookDeclineEventRequest request)
{
var responseMessage = request.ResponseMessage;
var calendarItem = request.Item;
var calendar = calendarItem.AssignedCalendar;
if (calendar == null)
{
throw new InvalidOperationException("Calendar item must have an assigned calendar");
}
if (string.IsNullOrEmpty(calendarItem.RemoteEventId))
{
throw new InvalidOperationException("Cannot decline event without remote event ID");
}
var declineRequestInfo = _graphClient.Me.Calendars[calendar.RemoteCalendarId].Events[calendarItem.RemoteEventId].Decline.ToPostRequestInformation(new Microsoft.Graph.Me.Calendars.Item.Events.Item.Decline.DeclinePostRequestBody
{
Comment = responseMessage,
SendResponse = !string.IsNullOrEmpty(responseMessage)
});
return [new HttpRequestBundle<RequestInformation>(declineRequestInfo, request)];
}
public override List<IRequestBundle<RequestInformation>> TentativeEvent(TentativeEventRequest request)
{
var calendarItem = request.Item;
var calendar = calendarItem.AssignedCalendar;
if (calendar == null)
{
throw new InvalidOperationException("Calendar item must have an assigned calendar");
}
if (string.IsNullOrEmpty(calendarItem.RemoteEventId))
{
throw new InvalidOperationException("Cannot tentatively accept event without remote event ID");
}
var tentativelyAcceptRequestInfo = _graphClient.Me.Calendars[calendar.RemoteCalendarId].Events[calendarItem.RemoteEventId].TentativelyAccept.ToPostRequestInformation(new Microsoft.Graph.Me.Calendars.Item.Events.Item.TentativelyAccept.TentativelyAcceptPostRequestBody
{
Comment = request.ResponseMessage,
SendResponse = !string.IsNullOrEmpty(request.ResponseMessage)
});
return [new HttpRequestBundle<RequestInformation>(tentativelyAcceptRequestInfo, request)];
}
#endregion
public override async Task KillSynchronizerAsync()
@@ -364,6 +364,22 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
case CalendarSynchronizerOperation.CreateEvent:
nativeRequests.AddRange(CreateCalendarEvent(group.ElementAt(0) as CreateCalendarEventRequest));
break;
case CalendarSynchronizerOperation.AcceptEvent:
nativeRequests.AddRange(AcceptEvent(group.ElementAt(0) as AcceptEventRequest));
break;
case CalendarSynchronizerOperation.DeclineEvent:
if (Account.ProviderType == MailProviderType.Outlook)
{
nativeRequests.AddRange(OutlookDeclineEvent(group.ElementAt(0) as OutlookDeclineEventRequest));
}
else
{
nativeRequests.AddRange(DeclineEvent(group.ElementAt(0) as DeclineEventRequest));
}
break;
case CalendarSynchronizerOperation.TentativeEvent:
nativeRequests.AddRange(TentativeEvent(group.ElementAt(0) as TentativeEventRequest));
break;
case CalendarSynchronizerOperation.UpdateEvent:
// TODO: Implement UpdateCalendarEvent
break;
@@ -494,6 +510,10 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
#region Calendar Operations
public virtual List<IRequestBundle<TBaseRequest>> CreateCalendarEvent(CreateCalendarEventRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
public virtual List<IRequestBundle<TBaseRequest>> AcceptEvent(AcceptEventRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
public virtual List<IRequestBundle<TBaseRequest>> DeclineEvent(DeclineEventRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
public virtual List<IRequestBundle<TBaseRequest>> OutlookDeclineEvent(OutlookDeclineEventRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
public virtual List<IRequestBundle<TBaseRequest>> TentativeEvent(TentativeEventRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
#endregion