package api import ( "bytes" "context" "encoding/json" "fmt" "io" "strconv" "strings" "time" "github.com/alcionai/clues" "github.com/microsoft/kiota-abstractions-go/serialization" kjson "github.com/microsoft/kiota-serialization-json-go" "github.com/microsoftgraph/msgraph-sdk-go/models" "github.com/microsoftgraph/msgraph-sdk-go/users" "github.com/alcionai/corso/src/internal/common/dttm" "github.com/alcionai/corso/src/internal/common/ptr" "github.com/alcionai/corso/src/internal/common/str" "github.com/alcionai/corso/src/internal/m365/graph" "github.com/alcionai/corso/src/pkg/backup/details" "github.com/alcionai/corso/src/pkg/fault" "github.com/alcionai/corso/src/pkg/logger" ) // --------------------------------------------------------------------------- // controller // --------------------------------------------------------------------------- func (c Client) Events() Events { return Events{c} } // Events is an interface-compliant provider of the client. type Events struct { Client } // --------------------------------------------------------------------------- // containers // --------------------------------------------------------------------------- // CreateContainer makes an event Calendar with the name in the user's M365 exchange account // Reference: https://docs.microsoft.com/en-us/graph/api/user-post-calendars?view=graph-rest-1.0&tabs=go func (c Events) CreateContainer( ctx context.Context, // parentContainerID needed for iface, doesn't apply to events userID, _, containerName string, ) (graph.Container, error) { body := models.NewCalendar() body.SetName(&containerName) container, err := c.Stable. Client(). Users(). ByUserId(userID). Calendars(). Post(ctx, body, nil) if err != nil { return nil, graph.Wrap(ctx, err, "creating calendar") } return CalendarDisplayable{Calendarable: container}, nil } // DeleteContainer removes a calendar from user's M365 account // Reference: https://docs.microsoft.com/en-us/graph/api/calendar-delete?view=graph-rest-1.0&tabs=go func (c Events) DeleteContainer( ctx context.Context, userID, containerID string, ) error { // deletes require unique http clients // https://github.com/alcionai/corso/issues/2707 srv, err := NewService(c.Credentials, c.counter) if err != nil { return graph.Stack(ctx, err) } err = srv.Client(). Users(). ByUserId(userID). Calendars(). ByCalendarId(containerID). Delete(ctx, nil) if err != nil { return graph.Stack(ctx, err) } return nil } func (c Events) GetContainerByID( ctx context.Context, userID, containerID string, ) (graph.Container, error) { config := &users.ItemCalendarsCalendarItemRequestBuilderGetRequestConfiguration{ QueryParameters: &users.ItemCalendarsCalendarItemRequestBuilderGetQueryParameters{ Select: idAnd("name", "owner"), }, } resp, err := c.Stable. Client(). Users(). ByUserId(userID). Calendars(). ByCalendarId(containerID). Get(ctx, config) if err != nil { return nil, graph.Stack(ctx, err) } return graph.CalendarDisplayable{Calendarable: resp}, nil } // GetContainerByName fetches a calendar by name func (c Events) GetContainerByName( ctx context.Context, // parentContainerID needed for iface, doesn't apply to events userID, _, containerName string, ) (graph.Container, error) { filter := fmt.Sprintf("name eq '%s'", containerName) options := &users.ItemCalendarsRequestBuilderGetRequestConfiguration{ QueryParameters: &users.ItemCalendarsRequestBuilderGetQueryParameters{ Filter: &filter, }, } ctx = clues.Add(ctx, "container_name", containerName) resp, err := c.Stable. Client(). Users(). ByUserId(userID). Calendars(). Get(ctx, options) if err != nil { return nil, graph.Stack(ctx, err).WithClues(ctx) } gv := resp.GetValue() if len(gv) == 0 { return nil, clues.New("container not found").WithClues(ctx) } // We only allow the api to match one calendar with the provided name. // If we match multiples, we'll eagerly return the first one. logger.Ctx(ctx).Debugw("calendars matched the name search", "calendar_count", len(gv)) // Sanity check ID and name cal := gv[0] container := graph.CalendarDisplayable{Calendarable: cal} if err := graph.CheckIDAndName(container); err != nil { return nil, clues.Stack(err).WithClues(ctx) } return container, nil } func (c Events) PatchCalendar( ctx context.Context, userID, containerID string, body models.Calendarable, ) error { _, err := c.Stable. Client(). Users(). ByUserId(userID). Calendars(). ByCalendarId(containerID). Patch(ctx, body, nil) if err != nil { return graph.Wrap(ctx, err, "patching event calendar") } return nil } const ( // Beta version cannot have /calendars/%s for get and Patch // https://stackoverflow.com/questions/50492177/microsoft-graph-get-user-calendar-event-with-beta-version eventExceptionsBetaURLTemplate = "https://graph.microsoft.com/beta/users/%s/events/%s?$expand=exceptionOccurrences" eventPostBetaURLTemplate = "https://graph.microsoft.com/beta/users/%s/calendars/%s/events" eventPatchBetaURLTemplate = "https://graph.microsoft.com/beta/users/%s/events/%s" ) // --------------------------------------------------------------------------- // items // --------------------------------------------------------------------------- // GetItem retrieves an Eventable item. func (c Events) GetItem( ctx context.Context, userID, itemID string, immutableIDs bool, errs *fault.Bus, ) (serialization.Parsable, *details.ExchangeInfo, error) { var ( err error event models.Eventable config = &users.ItemEventsEventItemRequestBuilderGetRequestConfiguration{ Headers: newPreferHeaders(preferImmutableIDs(immutableIDs)), } ) // Beta endpoint helps us fetch the event exceptions, but since we // don't use the beta SDK, the exceptionOccurrences and // cancelledOccurrences end up in AdditionalData // https://learn.microsoft.com/en-us/graph/api/resources/event?view=graph-rest-beta#properties rawURL := fmt.Sprintf(eventExceptionsBetaURLTemplate, userID, itemID) event, err = users. NewItemEventsEventItemRequestBuilder(rawURL, c.Stable.Adapter()). Get(ctx, config) if err != nil { return nil, nil, graph.Stack(ctx, err) } err = validateCancelledOccurrences(event) if err != nil { return nil, nil, clues.Wrap(err, "verify cancelled occurrences") } err = fixupExceptionOccurrences(ctx, c, event, immutableIDs, userID) if err != nil { return nil, nil, clues.Wrap(err, "fixup exception occurrences") } var attachments []models.Attachmentable if ptr.Val(event.GetHasAttachments()) || HasAttachments(event.GetBody()) { attachments, err = c.GetAttachments(ctx, immutableIDs, userID, itemID) if err != nil { return nil, nil, err } } event.SetAttachments(attachments) return event, EventInfo(event), nil } // fixupExceptionOccurrences gets attachments and converts the data // into a format that gets serialized when storing to kopia func fixupExceptionOccurrences( ctx context.Context, client Events, event models.Eventable, immutableIDs bool, userID string, ) error { // Fetch attachments for exceptions exceptionOccurrences := event.GetAdditionalData()["exceptionOccurrences"] if exceptionOccurrences == nil { return nil } eo, ok := exceptionOccurrences.([]any) if !ok { return clues.New("converting exceptionOccurrences to []any"). With("type", fmt.Sprintf("%T", exceptionOccurrences)) } for _, instance := range eo { instance, ok := instance.(map[string]any) if !ok { return clues.New("converting instance to map[string]any"). With("type", fmt.Sprintf("%T", instance)) } evt, err := EventFromMap(instance) if err != nil { return clues.Wrap(err, "parsing exception event") } // OPTIMIZATION: We don't have to store any of the // attachments that carry over from the original var attachments []models.Attachmentable if ptr.Val(event.GetHasAttachments()) || HasAttachments(event.GetBody()) { attachments, err = client.GetAttachments(ctx, immutableIDs, userID, ptr.Val(evt.GetId())) if err != nil { return clues.Wrap(err, "getting event instance attachments"). With("event_instance_id", ptr.Val(evt.GetId())) } } // This odd roundabout way of doing this is required as // the json serialization at the end does not serialize if // you just pass in a models.Attachmentable convertedAttachments := []map[string]any{} for _, attachment := range attachments { am, err := parseableToMap(attachment) if err != nil { return clues.Wrap(err, "converting attachment") } convertedAttachments = append(convertedAttachments, am) } instance["attachments"] = convertedAttachments } return nil } // Adding checks to ensure that the data is in the format that we expect M365 to return func validateCancelledOccurrences(event models.Eventable) error { cancelledOccurrences := event.GetAdditionalData()["cancelledOccurrences"] if cancelledOccurrences != nil { co, ok := cancelledOccurrences.([]any) if !ok { return clues.New("converting cancelledOccurrences to []any"). With("type", fmt.Sprintf("%T", cancelledOccurrences)) } for _, instance := range co { instance, err := str.AnyToString(instance) if err != nil { return err } // There might be multiple `.` in the ID and hence >2 splits := strings.Split(instance, ".") if len(splits) < 2 { return clues.New("unexpected cancelled event format"). With("instance", instance) } startStr := splits[len(splits)-1] _, err = dttm.ParseTime(startStr) if err != nil { return clues.Wrap(err, "parsing cancelled event date") } } } return nil } func parseableToMap(att serialization.Parsable) (map[string]any, error) { var item map[string]any writer := kjson.NewJsonSerializationWriter() defer writer.Close() if err := writer.WriteObjectValue("", att); err != nil { return nil, err } ats, err := writer.GetSerializedContent() if err != nil { return nil, err } err = json.Unmarshal(ats, &item) if err != nil { return nil, clues.Wrap(err, "unmarshalling serialized attachment") } return item, nil } func (c Events) GetAttachments( ctx context.Context, immutableIDs bool, userID, itemID string, ) ([]models.Attachmentable, error) { config := &users.ItemEventsItemAttachmentsRequestBuilderGetRequestConfiguration{ QueryParameters: &users.ItemEventsItemAttachmentsRequestBuilderGetQueryParameters{ Expand: []string{"microsoft.graph.itemattachment/item"}, }, Headers: newPreferHeaders(preferPageSize(maxNonDeltaPageSize), preferImmutableIDs(immutableIDs)), } attached, err := c.LargeItem. Client(). Users(). ByUserId(userID). Events(). ByEventId(itemID). Attachments(). Get(ctx, config) if err != nil { return nil, graph.Wrap(ctx, err, "event attachment download") } return attached.GetValue(), nil } func (c Events) DeleteAttachment( ctx context.Context, userID, calendarID, eventID, attachmentID string, ) error { return c.Stable. Client(). Users(). ByUserId(userID). Calendars(). ByCalendarId(calendarID). Events(). ByEventId(eventID). Attachments(). ByAttachmentId(attachmentID). Delete(ctx, nil) } func (c Events) GetItemInstances( ctx context.Context, userID, itemID, startDate, endDate string, ) ([]models.Eventable, error) { config := &users.ItemEventsItemInstancesRequestBuilderGetRequestConfiguration{ QueryParameters: &users.ItemEventsItemInstancesRequestBuilderGetQueryParameters{ Select: []string{"id"}, StartDateTime: ptr.To(startDate), EndDateTime: ptr.To(endDate), }, } events, err := c.Stable. Client(). Users(). ByUserId(userID). Events(). ByEventId(itemID). Instances(). Get(ctx, config) if err != nil { return nil, graph.Stack(ctx, err) } return events.GetValue(), nil } func (c Events) PostItem( ctx context.Context, userID, containerID string, body models.Eventable, ) (models.Eventable, error) { rawURL := fmt.Sprintf(eventPostBetaURLTemplate, userID, containerID) builder := users.NewItemCalendarsItemEventsRequestBuilder(rawURL, c.Stable.Adapter()) itm, err := builder.Post(ctx, body, nil) if err != nil { return nil, graph.Wrap(ctx, err, "creating calendar event") } return itm, nil } func (c Events) PatchItem( ctx context.Context, userID, eventID string, body models.Eventable, ) (models.Eventable, error) { rawURL := fmt.Sprintf(eventPatchBetaURLTemplate, userID, eventID) builder := users.NewItemCalendarsItemEventsEventItemRequestBuilder(rawURL, c.Stable.Adapter()) itm, err := builder.Patch(ctx, body, nil) if err != nil { return nil, graph.Wrap(ctx, err, "updating calendar event") } return itm, nil } func (c Events) DeleteItem( ctx context.Context, userID, itemID string, ) error { // deletes require unique http clients // https://github.com/alcionai/corso/issues/2707 srv, err := c.Service(c.counter) if err != nil { return graph.Stack(ctx, err) } err = srv. Client(). Users(). ByUserId(userID). Events(). ByEventId(itemID). Delete(ctx, nil) if err != nil { return graph.Wrap(ctx, err, "deleting calendar event") } return nil } func (c Events) PostSmallAttachment( ctx context.Context, userID, containerID, parentItemID string, body models.Attachmentable, ) error { _, err := c.Stable. Client(). Users(). ByUserId(userID). Calendars(). ByCalendarId(containerID). Events(). ByEventId(parentItemID). Attachments(). Post(ctx, body, nil) if err != nil { return graph.Wrap(ctx, err, "uploading small event attachment") } return nil } func (c Events) PostLargeAttachment( ctx context.Context, userID, containerID, parentItemID, itemName string, content []byte, ) (string, error) { size := int64(len(content)) session := users.NewItemCalendarEventsItemAttachmentsCreateUploadSessionPostRequestBody() session.SetAttachmentItem(makeSessionAttachment(itemName, size)) us, err := c.LargeItem. Client(). Users(). ByUserId(userID). Calendars(). ByCalendarId(containerID). Events(). ByEventId(parentItemID). Attachments(). CreateUploadSession(). Post(ctx, session, nil) if err != nil { return "", graph.Wrap(ctx, err, "uploading large event attachment") } url := ptr.Val(us.GetUploadUrl()) w := graph.NewLargeItemWriter(parentItemID, url, size, c.counter) copyBuffer := make([]byte, graph.AttachmentChunkSize) _, err = io.CopyBuffer(w, bytes.NewReader(content), copyBuffer) if err != nil { return "", clues.Wrap(err, "buffering large attachment content").WithClues(ctx) } return w.ID, nil } // --------------------------------------------------------------------------- // Serialization // --------------------------------------------------------------------------- func BytesToEventable(body []byte) (models.Eventable, error) { v, err := CreateFromBytes(body, models.CreateEventFromDiscriminatorValue) if err != nil { return nil, clues.Wrap(err, "deserializing bytes to event") } return v.(models.Eventable), nil } func (c Events) Serialize( ctx context.Context, item serialization.Parsable, userID, itemID string, ) ([]byte, error) { event, ok := item.(models.Eventable) if !ok { return nil, clues.New(fmt.Sprintf("item is not an Eventable: %T", item)) } ctx = clues.Add(ctx, "item_id", ptr.Val(event.GetId())) writer := kjson.NewJsonSerializationWriter() defer writer.Close() if err := writer.WriteObjectValue("", event); err != nil { return nil, graph.Stack(ctx, err) } bs, err := writer.GetSerializedContent() if err != nil { return nil, graph.Wrap(ctx, err, "serializing event") } return bs, nil } // --------------------------------------------------------------------------- // helper funcs // --------------------------------------------------------------------------- // CalendarDisplayable is a wrapper that complies with the // models.Calendarable interface with the graph.Container // interfaces. Calendars do not have a parentFolderID. // Therefore, that value will always return nil. type CalendarDisplayable struct { models.Calendarable } // GetDisplayName returns the *string of the models.Calendable // variant: calendar.GetName() func (c CalendarDisplayable) GetDisplayName() *string { return c.GetName() } // GetParentFolderId returns the default calendar name address // EventCalendars have a flat hierarchy and Calendars are rooted // at the default // //nolint:revive func (c CalendarDisplayable) GetParentFolderId() *string { return nil } func EventInfo(evt models.Eventable) *details.ExchangeInfo { var ( organizer string subject = ptr.Val(evt.GetSubject()) recurs bool start = time.Time{} end = time.Time{} created = ptr.Val(evt.GetCreatedDateTime()) ) if evt.GetOrganizer() != nil && evt.GetOrganizer().GetEmailAddress() != nil { organizer = ptr.Val(evt.GetOrganizer().GetEmailAddress().GetAddress()) } if evt.GetRecurrence() != nil { recurs = true } if evt.GetStart() != nil && len(ptr.Val(evt.GetStart().GetDateTime())) > 0 { // timeString has 'Z' literal added to ensure the stored // DateTime is not: time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC) startTime := ptr.Val(evt.GetStart().GetDateTime()) + "Z" output, err := dttm.ParseTime(startTime) if err == nil { start = output } } if evt.GetEnd() != nil && len(ptr.Val(evt.GetEnd().GetDateTime())) > 0 { // timeString has 'Z' literal added to ensure the stored // DateTime is not: time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC) endTime := ptr.Val(evt.GetEnd().GetDateTime()) + "Z" output, err := dttm.ParseTime(endTime) if err == nil { end = output } } return &details.ExchangeInfo{ ItemType: details.ExchangeEvent, Organizer: organizer, Subject: subject, EventStart: start, EventEnd: end, EventRecurs: recurs, Created: created, Modified: ptr.OrNow(evt.GetLastModifiedDateTime()), } } func EventFromMap(ev map[string]any) (models.Eventable, error) { instBytes, err := json.Marshal(ev) if err != nil { return nil, clues.Wrap(err, "marshaling event exception instance") } body, err := BytesToEventable(instBytes) if err != nil { return nil, clues.Wrap(err, "converting exception event bytes to Eventable") } return body, nil } func eventCollisionKeyProps() []string { // Do not use attendees here. We slice out attendees from the // restored event so that they do not receive an email for every // restoration item. Attendees will guarantee non-overlapping keys. return idAnd( "subject", "type", "start", "end", recurrence, isCancelled, isDraft) } // EventCollisionKey constructs a key from the eventable's creation time, subject, and organizer. // collision keys are used to identify duplicate item conflicts for handling advanced restoration config. func EventCollisionKey(item models.Eventable) string { if item == nil { return "" } var ( subject = ptr.Val(item.GetSubject()) oftype = ptr.Val(item.GetTypeEscaped()).String() startTime = item.GetStart() start string endTime = item.GetEnd() end string recurs = item.GetRecurrence() recur string cancelled = ptr.Val(item.GetIsCancelled()) draft = ptr.Val(item.GetIsDraft()) ) if startTime != nil { start = ptr.Val(startTime.GetDateTime()) } if endTime != nil { end = ptr.Val(endTime.GetDateTime()) } if recurs != nil && recurs.GetPattern() != nil { recur = ptr.Val(recurs.GetPattern().GetOdataType()) } // this result gets hashed to ensure that an enormous list of attendees // doesn't generate a multi-kb collision key. return subject + oftype + start + end + recur + strconv.FormatBool(draft) + strconv.FormatBool(cancelled) }