From 114fec60597ae7396e1a04ae93df4e598a23b6eb Mon Sep 17 00:00:00 2001 From: Danny Date: Thu, 29 Sep 2022 18:01:12 -0400 Subject: [PATCH] GC: Restore: Simplified Event Restore (#981) ## Description Implementation of simplified Restore based on the following [spec](https://www.notion.so/alcion/Event-restore-semantics-061aee5288244629b1c53337e4dea306#6e974540c8804c4fa832218675534e1c) ## Type of change - [x] :sunflower: Feature ## Issue(s) *closes #954 ## Test Plan - [x] :muscle: Manual --- .../exchange/exchange_service_test.go | 4 +- .../connector/exchange/service_restore.go | 4 +- .../mockconnector/mock_data_collection.go | 29 +++++ .../mock_data_collection_test.go | 45 +++++++ src/internal/connector/support/attendee.go | 113 ++++++++++++++++++ .../connector/support/m365Transform.go | 48 ++++++++ .../connector/support/m365Transform_test.go | 21 ++++ 7 files changed, 260 insertions(+), 4 deletions(-) create mode 100644 src/internal/connector/support/attendee.go diff --git a/src/internal/connector/exchange/exchange_service_test.go b/src/internal/connector/exchange/exchange_service_test.go index 746655e30..bf34e5132 100644 --- a/src/internal/connector/exchange/exchange_service_test.go +++ b/src/internal/connector/exchange/exchange_service_test.go @@ -426,8 +426,6 @@ func (suite *ExchangeServiceSuite) TestRestoreContact() { func (suite *ExchangeServiceSuite) TestRestoreEvent() { t := suite.T() ctx := context.Background() - // TODO: #779 - reinstate when restored events to not generate notifications - t.Skip("#779 - reinstate when restored events to not generate notifications") userID := tester.M365UserID(t) name := "TestRestoreEvent: " + common.FormatSimpleDateTime(time.Now()) calendar, err := CreateCalendar(ctx, suite.es, userID, name) @@ -435,7 +433,7 @@ func (suite *ExchangeServiceSuite) TestRestoreEvent() { calendarID := *calendar.GetId() err = RestoreExchangeEvent(context.Background(), - mockconnector.GetMockEventBytes("Restore Event "), + mockconnector.GetMockEventWithAttendeesBytes(name), suite.es, control.Copy, calendarID, diff --git a/src/internal/connector/exchange/service_restore.go b/src/internal/connector/exchange/service_restore.go index b9f4957c6..446b8b05f 100644 --- a/src/internal/connector/exchange/service_restore.go +++ b/src/internal/connector/exchange/service_restore.go @@ -150,7 +150,9 @@ func RestoreExchangeEvent( return err } - response, err := service.Client().UsersById(user).CalendarsById(destination).Events().Post(ctx, event, nil) + transformedEvent := support.ToEventSimplified(event) + + response, err := service.Client().UsersById(user).CalendarsById(destination).Events().Post(ctx, transformedEvent, nil) if err != nil { return errors.Wrap(err, fmt.Sprintf( diff --git a/src/internal/connector/mockconnector/mock_data_collection.go b/src/internal/connector/mockconnector/mock_data_collection.go index 388cbda1b..798036c68 100644 --- a/src/internal/connector/mockconnector/mock_data_collection.go +++ b/src/internal/connector/mockconnector/mock_data_collection.go @@ -271,6 +271,35 @@ func GetMockEventBytes(subject string) []byte { return []byte(event) } +func GetMockEventWithAttendeesBytes(subject string) []byte { + newTime := time.Now().AddDate(0, 0, 1) + conversion := common.FormatTime(newTime) + timeSlice := strings.Split(conversion, "T") + + //nolint:lll + event := "{\"id\":\"AAMkAGZmNjNlYjI3LWJlZWYtNGI4Mi04YjMyLTIxYThkNGQ4NmY1MwBGAAAAAADCNgjhM9QmQYWNcI7hCpPrBwDSEBNbUIB9RL6ePDeF3FIYAAAAAAENAADSEBNbUIB9RL6ePDeF3FIYAABU_FdvAAA=\",\"@odata.etag\":\"W/\\\"0hATW1CAfUS+njw3hdxSGAAAVK7j9A==\\\"\"," + + "\"calendar@odata.associationLink\":\"https://graph.microsoft.com/v1.0/users('a4a472f8-ccb0-43ec-bf52-3697a91b926c')/calendars('AAMkAGZmNjNlYjI3LWJlZWYtNGI4Mi04YjMyLTIxYThkNGQ4NmY1MwAuAAAAAADCNgjhM9QmQYWNcI7hCpPrAQDSEBNbUIB9RL6ePDeF3FIYAAAAAAENAAA=')/$ref\"," + + "\"calendar@odata.navigationLink\":\"https://graph.microsoft.com/v1.0/users('a4a472f8-ccb0-43ec-bf52-3697a91b926c')/calendars('AAMkAGZmNjNlYjI3LWJlZWYtNGI4Mi04YjMyLTIxYThkNGQ4NmY1MwAuAAAAAADCNgjhM9QmQYWNcI7hCpPrAQDSEBNbUIB9RL6ePDeF3FIYAAAAAAENAAA=')\"," + + "\"@odata.context\":\"https://graph.microsoft.com/v1.0/$metadata#users('a4a472f8-ccb0-43ec-bf52-3697a91b926c')/events/$entity\",\"categories\":[],\"changeKey\":\"0hATW1CAfUS+njw3hdxSGAAAVK7j9A==\",\"createdDateTime\":\"2022-08-06T12:47:56Z\",\"lastModifiedDateTime\":\"2022-08-06T12:49:59Z\",\"allowNewTimeProposals\":true," + + "\"attendees\":[{\"emailAddress\":{\"address\":\"george.martinez@8qzvrj.onmicrosoft.com\",\"name\":\"George Martinez\"},\"type\":\"required\",\"status\":{\"response\":\"none\",\"time\":\"0001-01-01T00:00:00Z\"}},{\"emailAddress\":{\"address\":\"LeeG@8qzvrj.onmicrosoft.com\",\"name\":\"Lee Gu\"},\"type\":\"required\",\"status\":{\"response\":\"none\",\"time\":\"0001-01-01T00:00:00Z\"}}]," + + "\"body\":{\"content\":\"\\n\\n\\n\\n\\n
\\nDiscuss matters concerning stock options and early release of quarterly earnings.
\\n
" + + "\\n
________________________________________________________________________________\\n
\\n
" + + "\\n
Microsoft Teams meeting\\n
\\n
\\n
Join on your computer or mobile app" + + "\\n
\\nClick\\n here to join the meeting
" + + "\\n
\\n
Meeting ID:\\n292 784 521 247
\\nPasscode: SzBkfK\\n" + + "\\n\\n
\\n
\\n
Learn more" + + "\\n | " + + "\\nMeeting options
\\n
\\n
\\n
\\n
\\n
\\n
________________________________________________________________________________" + + "\\n
\\n\\n\\n\",\"contentType\":\"html\"},\"bodyPreview\":\"Discuss matters concerning stock options and early release of quarterly earnings.\\n\\n\", " + + "\"end\":{\"dateTime\":\"" + timeSlice[0] + "T16:00:00.0000000\",\"timeZone\":\"UTC\"},\"hasAttachments\":false,\"hideAttendees\":false,\"iCalUId\":\"040000008200E00074C5B7101A82E0080000000010A45EC092A9D801000000000000000010000000999C7C6281C2B24A91D5502392B8EF38\",\"importance\":\"normal\",\"isAllDay\":false,\"isCancelled\":false,\"isDraft\":false,\"isOnlineMeeting\":true,\"isOrganizer\":true,\"isReminderOn\":true," + + "\"location\":{\"address\":{},\"coordinates\":{},\"displayName\":\"\",\"locationType\":\"default\",\"uniqueIdType\":\"unknown\"},\"locations\":[],\"onlineMeeting\":{\"joinUrl\":\"https://teams.microsoft.com/l/meetup-join/19%3ameeting_YWNhMzAxZjItMzE2My00ZGQzLTkzMDUtNjQ3NTY0NjNjMTZi%40thread.v2/0?context=%7b%22Tid%22%3a%224d603060-18d6-4764-b9be-4cb794d32b69%22%2c%22Oid%22%3a%22a4a472f8-ccb0-43ec-bf52-3697a91b926c%22%7d\"},\"onlineMeetingProvider\":\"teamsForBusiness\"," + + "\"organizer\":{\"emailAddress\":{\"address\":\"LidiaH@8qzvrj.onmicrosoft.com\",\"name\":\"Lidia Holloway\"}},\"originalEndTimeZone\":\"Eastern Standard Time\",\"originalStartTimeZone\":\"Eastern Standard Time\",\"reminderMinutesBeforeStart\":15,\"responseRequested\":true,\"responseStatus\":{\"response\":\"organizer\",\"time\":\"0001-01-01T00:00:00Z\"},\"sensitivity\":\"normal\",\"showAs\":\"busy\"," + + "\"start\":{\"dateTime\":\"" + timeSlice[0] + "T15:30:00.0000000\",\"timeZone\":\"UTC\"},\"subject\":\"Board " + subject + " Meeting\",\"transactionId\":\"28b36295-6cd3-952f-d8f5-deb313444a51\",\"type\":\"singleInstance\",\"webLink\":\"https://outlook.office365.com/owa/?itemid=AAMkAGZmNjNlYjI3LWJlZWYtNGI4Mi04YjMyLTIxYThkNGQ4NmY1MwBGAAAAAADCNgjhM9QmQYWNcI7hCpPrBwDSEBNbUIB9RL6ePDeF3FIYAAAAAAENAADSEBNbUIB9RL6ePDeF3FIYAABU%2BFdvAAA%3D&exvsurl=1&path=/calendar/item\"}" + + return []byte(event) +} + type errReader struct { readErr error } diff --git a/src/internal/connector/mockconnector/mock_data_collection_test.go b/src/internal/connector/mockconnector/mock_data_collection_test.go index 013763ea2..7b82b9b48 100644 --- a/src/internal/connector/mockconnector/mock_data_collection_test.go +++ b/src/internal/connector/mockconnector/mock_data_collection_test.go @@ -121,3 +121,48 @@ func (suite *MockExchangeDataSuite) TestMockExchangeData() { }) } } + +func (suite *MockExchangeDataSuite) TestMockByteHydration() { + subject := "Mock Hydration" + tests := []struct { + name string + transformation func(t *testing.T) error + }{ + { + name: "Message Bytes", + transformation: func(t *testing.T) error { + bytes := mockconnector.GetMockMessageBytes(subject) + _, err := support.CreateMessageFromBytes(bytes) + return err + }, + }, { + name: "Contact Bytes", + transformation: func(t *testing.T) error { + bytes := mockconnector.GetMockContactBytes(subject) + _, err := support.CreateContactFromBytes(bytes) + return err + }, + }, { + name: "Event No Attendees Bytes", + transformation: func(t *testing.T) error { + bytes := mockconnector.GetMockEventBytes(subject) + _, err := support.CreateEventFromBytes(bytes) + return err + }, + }, { + name: "Event w/ Attendees Bytes", + transformation: func(t *testing.T) error { + bytes := mockconnector.GetMockEventWithAttendeesBytes(subject) + _, err := support.CreateEventFromBytes(bytes) + return err + }, + }, + } + + for _, test := range tests { + suite.T().Run(test.name, func(t *testing.T) { + err := test.transformation(t) + assert.NoError(t, err) + }) + } +} diff --git a/src/internal/connector/support/attendee.go b/src/internal/connector/support/attendee.go new file mode 100644 index 000000000..9c55d9890 --- /dev/null +++ b/src/internal/connector/support/attendee.go @@ -0,0 +1,113 @@ +package support + +import ( + "fmt" + + "github.com/microsoftgraph/msgraph-sdk-go/models" +) + +type attendee struct { + name string + email string + response string +} + +// FormatAttendees returns string representation of an attendee +// Return Format: - Name , Accepted | Declined | Tentative | No Response +func FormatAttendees(event models.Eventable, isHTML bool) string { + var ( + failed int + response = event.GetAttendees() + required = make([]attendee, 0) + optional = make([]attendee, 0) + resource = make([]attendee, 0) + ) + + for _, entry := range response { + if guardCheckForAttendee(entry) { + failed++ + continue + } + + temp := attendee{ + name: *entry.GetEmailAddress().GetName(), + email: *entry.GetEmailAddress().GetAddress(), + response: entry.GetStatus().GetResponse().String(), + } + + switch *entry.GetType() { + case models.REQUIRED_ATTENDEETYPE: + required = append(required, temp) + + case models.OPTIONAL_ATTENDEETYPE: + optional = append(optional, temp) + + case models.RESOURCE_ATTENDEETYPE: + resource = append(resource, temp) + } + } + + req := attendeeListToString(required, "Required", isHTML) + opt := attendeeListToString(optional, "Optional", isHTML) + res := attendeeListToString(resource, "Resource", isHTML) + + return req + opt + res +} + +func attendeeListToString(attendList []attendee, heading string, isHTML bool) string { + var ( + message string + lineBreak string + ) + + if isHTML { + lineBreak = "
" + } else { + lineBreak = "\n" + } + + if len(attendList) > 0 { + message = heading + ":" + lineBreak + for _, resource := range attendList { + message += "- " + resource.String(isHTML) + " " + lineBreak + } + + message += lineBreak + lineBreak + } + + return message +} + +func guardCheckForAttendee(attendee models.Attendeeable) bool { + if attendee.GetType() == nil || + attendee.GetStatus() == nil { + return true + } + + if attendee.GetStatus().GetResponse() == nil { + return true + } + + if attendee.GetEmailAddress() == nil { + return true + } + + if attendee.GetEmailAddress().GetName() == nil || + attendee.GetEmailAddress().GetAddress() == nil { + return true + } + + return false +} + +// String function to return struct representation of attendee +func (at *attendee) String(isHTML bool) string { + var contents string + if isHTML { + contents = fmt.Sprintf("%s <%s>, %s", at.name, at.email, at.response) + } else { + contents = fmt.Sprintf("%s <%s>, %s", at.name, at.email, at.response) + } + + return contents +} diff --git a/src/internal/connector/support/m365Transform.go b/src/internal/connector/support/m365Transform.go index 6c613ecde..bfbbbd69c 100644 --- a/src/internal/connector/support/m365Transform.go +++ b/src/internal/connector/support/m365Transform.go @@ -3,6 +3,7 @@ package support import ( "fmt" "strconv" + "strings" kw "github.com/microsoft/kiota-serialization-json-go" "github.com/microsoftgraph/msgraph-sdk-go/models" @@ -400,3 +401,50 @@ func SetAdditionalDataToEventMessage( return newMessage, nil } + +// ToEventSimplified transforms an event to simplifed restore format +// To overcome some of the MS Graph API challenges, the event object is modified in the following ways: +// * Instead of adding attendees and generating spurious notifications, +// add a summary of attendees at the beginning to the event before the original body content +// * event.attendees is set to an empty list +func ToEventSimplified(orig models.Eventable) models.Eventable { + attendees := FormatAttendees(orig, *orig.GetBody().GetContentType() == models.HTML_BODYTYPE) + orig.SetAttendees([]models.Attendeeable{}) + origBody := orig.GetBody() + newContent := insertStringToBody(origBody, attendees) + newBody := models.NewItemBody() + newBody.SetContentType(origBody.GetContentType()) + newBody.SetAdditionalData(origBody.GetAdditionalData()) + newBody.SetOdataType(origBody.GetOdataType()) + newBody.SetContent(&newContent) + orig.SetBody(newBody) + + return orig +} + +// insertStringToBody helper function to insert text into models.bodyable +// @returns string containing the content string of altered body. +func insertStringToBody(body models.ItemBodyable, newContent string) string { + var prefix, suffix string + + if body.GetContent() == nil || + body.GetContentType() == nil { + return "" + } + + content := *body.GetContent() + + switch *body.GetContentType() { + case models.TEXT_BODYTYPE: + return newContent + content + case models.HTML_BODYTYPE: + array := strings.Split(content, "") + prefix = array[0] + "" + interior := array[1] + bodyArray := strings.Split(interior, ">") + prefix += bodyArray[0] + ">" + suffix = strings.Join(bodyArray[1:], ">") + } + + return prefix + newContent + suffix +} diff --git a/src/internal/connector/support/m365Transform_test.go b/src/internal/connector/support/m365Transform_test.go index d7a6584ef..e38f72d29 100644 --- a/src/internal/connector/support/m365Transform_test.go +++ b/src/internal/connector/support/m365Transform_test.go @@ -3,6 +3,7 @@ package support import ( "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" @@ -29,3 +30,23 @@ func (suite *SupportTestSuite) TestToMessage() { suite.Equal(message.GetSentDateTime(), clone.GetSentDateTime()) suite.NotEqual(message.GetId(), clone.GetId()) } + +func (suite *SupportTestSuite) TestToEventSimplified() { + t := suite.T() + bytes := mockconnector.GetMockEventWithAttendeesBytes("M365 Event Support Test") + event, err := CreateEventFromBytes(bytes) + require.NoError(t, err) + + attendees := event.GetAttendees() + newEvent := ToEventSimplified(event) + + assert.Empty(t, newEvent.GetHideAttendees()) + assert.Equal(t, *event.GetBody().GetContentType(), *newEvent.GetBody().GetContentType()) + assert.Equal(t, event.GetBody().GetAdditionalData(), newEvent.GetBody().GetAdditionalData()) + assert.Contains(t, *event.GetBody().GetContent(), "Required:") + + for _, member := range attendees { + assert.Contains(t, *event.GetBody().GetContent(), *member.GetEmailAddress().GetName()) + assert.Contains(t, *event.GetBody().GetContent(), *member.GetEmailAddress().GetAddress()) + } +}