package migration import ( "context" "encoding/json" "fmt" "net/http" "net/url" "strings" "time" "github.com/jackc/pgx/v5/pgxpool" "github.com/ultisuite/ulti-backend/internal/nextcloud" ) const migrationCalendarID = "migration-import" type CalendarImporter struct { db *pgxpool.Pool nc *nextcloud.Client client *http.Client userUPN string } func NewCalendarImporter(db *pgxpool.Pool, nc *nextcloud.Client) *CalendarImporter { return &CalendarImporter{db: db, nc: nc, client: migrationHTTPClient()} } func (c *CalendarImporter) WithUserPrincipal(upn string) *CalendarImporter { c.userUPN = strings.TrimSpace(upn) return c } func (c *CalendarImporter) WithHTTPClient(client *http.Client) *CalendarImporter { if client != nil { c.client = client } return c } func (c *CalendarImporter) ImportBatch(ctx context.Context, job *Job, accessToken, provider string, delta bool, update progressUpdater) error { if c.nc == nil { return fmt.Errorf("nextcloud required for calendar migration") } user, err := resolveMigrationUser(ctx, c.db, job.UserID) if err != nil { return err } ncUserID := nextcloud.UserIDFromClaims(user.Email, user.ExternalID) if _, err := c.nc.EnsurePrincipal(ctx, user.Email, user.ExternalID, user.Name); err != nil { return fmt.Errorf("nextcloud user: %w", err) } calPath, err := c.ensureMigrationCalendar(ctx, ncUserID) if err != nil { return err } items, err := LoadImportedItemStore(ctx, c.db, job.ID, job.CursorJSON) if err != nil { return err } if delta && c.hasDeltaCursor(job, provider) { return c.importDelta(ctx, job, accessToken, provider, ncUserID, calPath, items, update) } return c.importFull(ctx, job, accessToken, provider, ncUserID, calPath, delta, items, update) } func (c *CalendarImporter) hasDeltaCursor(job *Job, provider string) bool { if provider == "google" { return len(calendarSyncTokens(job.CursorJSON)) > 0 } return len(calendarDeltaLinks(job.CursorJSON)) > 0 } func (c *CalendarImporter) importFull(ctx context.Context, job *Job, accessToken, provider, ncUserID, calPath string, captureDelta bool, items *ImportedItemStore, update progressUpdater) error { imported, _ := job.StatsJSON["imported"].(float64) batch := 0 calIndex := int(jsonNumber(job.CursorJSON["calendarIndex"])) sourceCalendars, err := c.listSourceCalendars(ctx, accessToken, provider) if err != nil { return err } if len(sourceCalendars) == 0 { job.StatsJSON["imported"] = imported job.StatsJSON["phase"] = "imported" return update("completed", job.CursorJSON, job.StatsJSON, "") } if calIndex >= len(sourceCalendars) { job.StatsJSON["imported"] = imported job.StatsJSON["phase"] = "imported" return update("completed", job.CursorJSON, job.StatsJSON, "") } sourceCal := sourceCalendars[calIndex] pageToken, _ := job.CursorJSON["pageToken"].(string) events, nextToken, syncToken, err := c.listSourceEvents(ctx, accessToken, provider, sourceCal, pageToken, "") if err != nil { return err } listIndex := int(jsonNumber(job.CursorJSON["listIndex"])) for i := listIndex; i < len(events) && batch < mailImportBatchSize(); i++ { ev := events[i] if alreadyImported(items, ev.SourceID) { continue } if err := c.nc.CreateEvent(ctx, ncUserID, calPath, ev.ToNextcloudEvent(provider)); err != nil { if markErr := items.MarkFailed(ctx, ev.SourceID, err.Error(), ""); markErr != nil { return markErr } incJobStat(job.StatsJSON, "failed") batch++ continue } if err := items.MarkImported(ctx, ev.SourceID); err != nil { return err } imported++ batch++ } job.StatsJSON["imported"] = imported if listIndex+batch < len(events) { job.CursorJSON["listIndex"] = float64(listIndex + batch) return update("pending", job.CursorJSON, job.StatsJSON, "") } delete(job.CursorJSON, "listIndex") if nextToken != "" { job.CursorJSON["pageToken"] = nextToken return update("pending", job.CursorJSON, job.StatsJSON, "") } delete(job.CursorJSON, "pageToken") if captureDelta { if provider == "google" && syncToken != "" { setCalendarSyncToken(job.CursorJSON, sourceCal.ID, syncToken) } if provider != "google" { if link, err := c.bootstrapCalendarDelta(ctx, accessToken, sourceCal.ID); err == nil && link != "" { setCalendarDeltaLink(job.CursorJSON, sourceCal.ID, link) } } } job.CursorJSON["calendarIndex"] = float64(calIndex + 1) return update("pending", job.CursorJSON, job.StatsJSON, "") } func (c *CalendarImporter) importDelta(ctx context.Context, job *Job, accessToken, provider, ncUserID, calPath string, items *ImportedItemStore, update progressUpdater) error { calIndex := int(jsonNumber(job.CursorJSON["calendarIndex"])) sourceCalendars, err := c.listSourceCalendars(ctx, accessToken, provider) if err != nil { return err } if calIndex >= len(sourceCalendars) { job.StatsJSON["phase"] = "delta" return update("completed", job.CursorJSON, job.StatsJSON, "") } sourceCal := sourceCalendars[calIndex] imported, _ := job.StatsJSON["delta_imported"].(float64) updated, _ := job.StatsJSON["delta_updated"].(float64) deleted, _ := job.StatsJSON["delta_deleted"].(float64) var events []sourceEvent var nextCursor string if provider == "google" { syncToken := calendarSyncTokens(job.CursorJSON)[sourceCal.ID] pageToken, _ := job.CursorJSON["pageToken"].(string) var syncTokenOut string events, nextCursor, syncTokenOut, err = c.listSourceEvents(ctx, accessToken, provider, sourceCal, pageToken, syncToken) if syncTokenOut != "" { setCalendarSyncToken(job.CursorJSON, sourceCal.ID, syncTokenOut) } } else { deltaLink := calendarDeltaLinks(job.CursorJSON)[sourceCal.ID] if deltaLink == "" { deltaLink, _ = job.CursorJSON["pageToken"].(string) } events, nextCursor, err = c.listMicrosoftCalendarDelta(ctx, accessToken, sourceCal.ID, deltaLink) if nextCursor != "" && strings.Contains(nextCursor, "delta") { setCalendarDeltaLink(job.CursorJSON, sourceCal.ID, nextCursor) } } if err != nil { return err } batch := 0 listIndex := int(jsonNumber(job.CursorJSON["listIndex"])) for i := listIndex; i < len(events) && batch < mailImportBatchSize(); i++ { ev := events[i] if ev.Deleted { eventPath := migrationEventPath(calPath, provider, ev.SourceID) if err := c.nc.DeleteEvent(ctx, ncUserID, eventPath); err != nil && !isDeleteNotFound(err) { return err } if err := items.Unmark(ctx, ev.SourceID); err != nil { return err } deleted++ batch++ continue } wasUpdate, err := c.upsertEvent(ctx, ncUserID, calPath, provider, ev, items) if err != nil { return err } if wasUpdate { updated++ } else if items.Has(ev.SourceID) { imported++ } else { incJobStat(job.StatsJSON, "failed") } batch++ } job.StatsJSON["delta_imported"] = imported job.StatsJSON["delta_updated"] = updated job.StatsJSON["delta_deleted"] = deleted if listIndex+batch < len(events) { job.CursorJSON["listIndex"] = float64(listIndex + batch) return update("pending", job.CursorJSON, job.StatsJSON, "") } delete(job.CursorJSON, "listIndex") if nextCursor != "" { job.CursorJSON["pageToken"] = nextCursor return update("pending", job.CursorJSON, job.StatsJSON, "") } delete(job.CursorJSON, "pageToken") job.CursorJSON["calendarIndex"] = float64(calIndex + 1) return update("pending", job.CursorJSON, job.StatsJSON, "") } func (c *CalendarImporter) bootstrapCalendarDelta(ctx context.Context, accessToken, calID string) (string, error) { url := graphMicrosoftURL(c.userUPN, fmt.Sprintf("/calendars/%s/events/delta?$select=id,subject,body,start,end,isAllDay,location", url.PathEscape(calID))) body, err := apiGet(ctx, c.client, url, accessToken) if err != nil { return "", err } var parsed struct { NextLink string `json:"@odata.nextLink"` DeltaLink string `json:"@odata.deltaLink"` } if err := json.Unmarshal(body, &parsed); err != nil { return "", err } if parsed.DeltaLink != "" { return parsed.DeltaLink, nil } return parsed.NextLink, nil } func (c *CalendarImporter) listMicrosoftCalendarDelta(ctx context.Context, accessToken, calID, deltaLink string) ([]sourceEvent, string, error) { if deltaLink == "" { deltaLink = graphMicrosoftURL(c.userUPN, fmt.Sprintf("/calendars/%s/events/delta?$select=id,subject,body,start,end,isAllDay,location", url.PathEscape(calID))) } body, err := apiGet(ctx, c.client, deltaLink, accessToken) if err != nil { return nil, "", err } var parsed struct { Value []graphCalendarEvent `json:"value"` NextLink string `json:"@odata.nextLink"` DeltaLink string `json:"@odata.deltaLink"` } if err := json.Unmarshal(body, &parsed); err != nil { return nil, "", err } out := make([]sourceEvent, 0, len(parsed.Value)) for _, item := range parsed.Value { out = append(out, item.toSourceEvent(calID)) } next := parsed.NextLink if parsed.DeltaLink != "" && parsed.NextLink == "" { next = parsed.DeltaLink } return out, next, nil } func (c *CalendarImporter) ensureMigrationCalendar(ctx context.Context, ncUserID string) (string, error) { path := fmt.Sprintf("/remote.php/dav/calendars/%s/%s/", ncUserID, migrationCalendarID) if err := c.nc.CreateCalendar(ctx, ncUserID, migrationCalendarID, "Migration Import", "#1a73e8"); err != nil { msg := strings.ToLower(err.Error()) if !strings.Contains(msg, "405") && !strings.Contains(msg, "409") && !strings.Contains(msg, "423") { return "", err } } return path, nil } type sourceCalendar struct { ID string Name string } type sourceEvent struct { SourceID string Summary string Description string Location string Start time.Time End time.Time AllDay bool Deleted bool } func (e sourceEvent) ToNextcloudEvent(provider string) *nextcloud.Event { uid := sanitizeMigrationUID(provider, e.SourceID) start := e.Start.UTC() end := e.End.UTC() if end.IsZero() || !end.After(start) { end = start.Add(time.Hour) } ev := &nextcloud.Event{ UID: uid, Summary: e.Summary, Description: e.Description, Location: e.Location, AllDay: e.AllDay, } if e.AllDay { ev.Start = start.Format("20060102") ev.End = end.Format("20060102") } else { ev.Start = start.Format("20060102T150405Z") ev.End = end.Format("20060102T150405Z") } return ev } func (c *CalendarImporter) listSourceCalendars(ctx context.Context, accessToken, provider string) ([]sourceCalendar, error) { switch provider { case "google": body, err := apiGet(ctx, c.client, "https://www.googleapis.com/calendar/v3/users/me/calendarList?maxResults=100", accessToken) if err != nil { return nil, err } var parsed struct { Items []struct { ID string `json:"id"` Summary string `json:"summary"` } `json:"items"` } if err := json.Unmarshal(body, &parsed); err != nil { return nil, err } out := make([]sourceCalendar, 0, len(parsed.Items)) for _, item := range parsed.Items { out = append(out, sourceCalendar{ID: item.ID, Name: item.Summary}) } return out, nil default: body, err := apiGet(ctx, c.client, graphMicrosoftURL(c.userUPN, "/calendars?$top=100"), accessToken) if err != nil { return nil, err } var parsed struct { Value []struct { ID string `json:"id"` Name string `json:"name"` } `json:"value"` } if err := json.Unmarshal(body, &parsed); err != nil { return nil, err } out := make([]sourceCalendar, 0, len(parsed.Value)) for _, item := range parsed.Value { out = append(out, sourceCalendar{ID: item.ID, Name: item.Name}) } return out, nil } } func (c *CalendarImporter) listSourceEvents(ctx context.Context, accessToken, provider string, cal sourceCalendar, pageToken, syncToken string) ([]sourceEvent, string, string, error) { switch provider { case "google": listURL := fmt.Sprintf( "https://www.googleapis.com/calendar/v3/calendars/%s/events?maxResults=100&singleEvents=true&orderBy=startTime", url.PathEscape(cal.ID), ) if syncToken != "" { listURL += "&syncToken=" + url.QueryEscape(syncToken) + "&showDeleted=true" } else if pageToken != "" { listURL += "&pageToken=" + url.QueryEscape(pageToken) } body, err := apiGet(ctx, c.client, listURL, accessToken) if err != nil { return nil, "", "", err } var parsed struct { Items []googleCalendarEvent `json:"items"` NextPageToken string `json:"nextPageToken"` NextSyncToken string `json:"nextSyncToken"` } if err := json.Unmarshal(body, &parsed); err != nil { return nil, "", "", err } out := make([]sourceEvent, 0, len(parsed.Items)) for _, item := range parsed.Items { out = append(out, item.toSourceEvent(cal.ID)) } return out, parsed.NextPageToken, parsed.NextSyncToken, nil default: listURL := graphMicrosoftURL(c.userUPN, fmt.Sprintf("/calendars/%s/events?$top=100&$select=id,subject,body,start,end,isAllDay,location", url.PathEscape(cal.ID))) if pageToken != "" { listURL = pageToken } body, err := apiGet(ctx, c.client, listURL, accessToken) if err != nil { return nil, "", "", err } var parsed struct { Value []graphCalendarEvent `json:"value"` NextLink string `json:"@odata.nextLink"` } if err := json.Unmarshal(body, &parsed); err != nil { return nil, "", "", err } out := make([]sourceEvent, 0, len(parsed.Value)) for _, item := range parsed.Value { out = append(out, item.toSourceEvent(cal.ID)) } return out, parsed.NextLink, "", nil } } type googleCalendarEvent struct { ID string `json:"id"` Status string `json:"status"` Summary string `json:"summary"` Description string `json:"description"` Location string `json:"location"` Start struct { DateTime string `json:"dateTime"` Date string `json:"date"` } `json:"start"` End struct { DateTime string `json:"dateTime"` Date string `json:"date"` } `json:"end"` } func (e googleCalendarEvent) toSourceEvent(calID string) sourceEvent { allDay := e.Start.Date != "" start := parseFlexibleTime(e.Start.DateTime, e.Start.Date) end := parseFlexibleTime(e.End.DateTime, e.End.Date) return sourceEvent{ SourceID: calID + ":" + e.ID, Summary: e.Summary, Description: e.Description, Location: e.Location, Start: start, End: end, AllDay: allDay, Deleted: e.Status == "cancelled", } } type graphCalendarEvent struct { ID string `json:"id"` Removed *struct { Reason string `json:"reason"` } `json:"@removed"` Subject string `json:"subject"` Body struct { Content string `json:"content"` } `json:"body"` IsAllDay bool `json:"isAllDay"` Location struct { DisplayName string `json:"displayName"` } `json:"location"` Start graphDateTime `json:"start"` End graphDateTime `json:"end"` } type graphDateTime struct { DateTime string `json:"dateTime"` Date string `json:"date"` } func (e graphCalendarEvent) toSourceEvent(calID string) sourceEvent { allDay := e.IsAllDay || e.Start.Date != "" start := parseFlexibleTime(e.Start.DateTime, e.Start.Date) end := parseFlexibleTime(e.End.DateTime, e.End.Date) return sourceEvent{ SourceID: calID + ":" + e.ID, Summary: e.Subject, Description: e.Body.Content, Location: e.Location.DisplayName, Start: start, End: end, AllDay: allDay, Deleted: e.Removed != nil, } } func parseFlexibleTime(dateTime, date string) time.Time { if strings.TrimSpace(dateTime) != "" { if t, err := time.Parse(time.RFC3339, dateTime); err == nil { return t.UTC() } } if strings.TrimSpace(date) != "" { if t, err := time.Parse("2006-01-02", date); err == nil { return t.UTC() } } return time.Time{} } func (c *CalendarImporter) upsertEvent( ctx context.Context, ncUserID, calPath, provider string, ev sourceEvent, items *ImportedItemStore, ) (updated bool, err error) { ncEv := ev.ToNextcloudEvent(provider) if alreadyImported(items, ev.SourceID) { eventPath := migrationEventPath(calPath, provider, ev.SourceID) if _, err := c.nc.UpdateEvent(ctx, ncUserID, eventPath, "", ncEv); err != nil { return false, err } return true, nil } if err := c.nc.CreateEvent(ctx, ncUserID, calPath, ncEv); err != nil { if markErr := items.MarkFailed(ctx, ev.SourceID, err.Error(), ""); markErr != nil { return false, markErr } return false, nil } if err := items.MarkImported(ctx, ev.SourceID); err != nil { return false, err } return false, nil }