package handlers // import_data.go — v0.34.0 CS1 // // Data import endpoint: user data import from .switchboard archives. // Reads the archive, validates manifest, and imports entities in // FK-dependency order with UUID dedup (skip if ID exists). import ( "fmt" "io" "log/slog" "net/http" "os" "path/filepath" "strings" "github.com/gin-gonic/gin" "switchboard-core/export" "switchboard-core/models" "switchboard-core/storage" "switchboard-core/store" ) // DataImportHandler serves data import endpoints. type DataImportHandler struct { stores store.Stores objStore storage.ObjectStore } // NewDataImportHandler creates a new handler for data import operations. func NewDataImportHandler(s store.Stores, obj storage.ObjectStore) *DataImportHandler { return &DataImportHandler{stores: s, objStore: obj} } // ImportMyData imports user data from a .switchboard zip archive. // POST /api/v1/import/me func (h *DataImportHandler) ImportMyData(c *gin.Context) { ctx := c.Request.Context() userID := c.GetString("user_id") // ── Receive upload ── file, header, err := c.Request.FormFile("file") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "missing file field"}) return } defer file.Close() // Validate extension if !strings.HasSuffix(header.Filename, export.ExportExtension) && !strings.HasSuffix(header.Filename, ".zip") { c.JSON(http.StatusBadRequest, gin.H{"error": "file must be a .switchboard or .zip archive"}) return } // Size check: use MaxExportArchiveSize if header.Size > export.MaxExportArchiveSize { c.JSON(http.StatusBadRequest, gin.H{ "error": fmt.Sprintf("archive too large (max %d MB)", export.MaxExportArchiveSize/(1024*1024)), }) return } // Save to temp file (zip.OpenReader needs a file path) tmp, err := os.CreateTemp("", "switchboard-import-*.zip") if err != nil { slog.Error("import: create temp file", "error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"}) return } defer os.Remove(tmp.Name()) defer tmp.Close() if _, err := io.Copy(tmp, file); err != nil { slog.Error("import: save temp file", "error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save upload"}) return } tmp.Close() // ── Open archive ── ar, err := export.OpenArchive(tmp.Name()) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid archive: " + err.Error()}) return } defer ar.Close() manifest, err := ar.ReadManifest() if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid manifest: " + err.Error()}) return } if manifest.FormatVersion > export.FormatVersion { c.JSON(http.StatusBadRequest, gin.H{ "error": fmt.Sprintf("unsupported format version %d (max %d)", manifest.FormatVersion, export.FormatVersion), }) return } // ── Read entities from archive ── imported := make(map[string]int) skipped := make(map[string]int) var errors []string importEntity := func(name string, fn func() (int, int, error)) { imp, skip, err := fn() if err != nil { errors = append(errors, fmt.Sprintf("%s: %v", name, err)) slog.Error("import: entity error", "entity", name, "error", err) } if imp > 0 { imported[name] = imp } if skip > 0 { skipped[name] = skip } } // Helper to remap user_id on entities for cross-instance import remapUserID := manifest.Scope != nil && manifest.Scope.UserID != "" && manifest.Scope.UserID != userID sourceUserID := "" if manifest.Scope != nil { sourceUserID = manifest.Scope.UserID } // 1. Folders var folders []models.Folder if err := ar.ReadEntityJSON("folders", &folders); err == nil && len(folders) > 0 { if remapUserID { for i := range folders { if folders[i].UserID == sourceUserID { folders[i].UserID = userID } } } importEntity("folders", func() (int, int, error) { return h.stores.Export.ImportFolders(ctx, folders) }) } // 2. Projects var projects []models.Project if err := ar.ReadEntityJSON("projects", &projects); err == nil && len(projects) > 0 { if remapUserID { for i := range projects { if projects[i].OwnerID == sourceUserID { projects[i].OwnerID = userID } } } importEntity("projects", func() (int, int, error) { return h.stores.Export.ImportProjects(ctx, projects) }) } // 3. Workspaces var workspaces []models.Workspace if err := ar.ReadEntityJSON("workspaces", &workspaces); err == nil && len(workspaces) > 0 { if remapUserID { for i := range workspaces { if workspaces[i].OwnerID == sourceUserID { workspaces[i].OwnerID = userID } } } importEntity("workspaces", func() (int, int, error) { return h.stores.Export.ImportWorkspaces(ctx, workspaces) }) } // 4. Channels var channels []models.Channel if err := ar.ReadEntityJSON("channels", &channels); err == nil && len(channels) > 0 { if remapUserID { for i := range channels { if channels[i].UserID == sourceUserID { channels[i].UserID = userID } } } importEntity("channels", func() (int, int, error) { return h.stores.Export.ImportChannels(ctx, channels) }) } // 5. Channel participants var participants []models.ChannelParticipant if err := ar.ReadEntityJSON("channel_participants", &participants); err == nil && len(participants) > 0 { if remapUserID { for i := range participants { if participants[i].ParticipantType == "user" && participants[i].ParticipantID == sourceUserID { participants[i].ParticipantID = userID } } } importEntity("channel_participants", func() (int, int, error) { return h.stores.Export.ImportChannelParticipants(ctx, participants) }) } // 5b. Channel models var channelModels []models.ChannelModel if err := ar.ReadEntityJSON("channel_models", &channelModels); err == nil && len(channelModels) > 0 { importEntity("channel_models", func() (int, int, error) { return h.stores.Export.ImportChannelModels(ctx, channelModels) }) } // 5c. Channel cursors var cursors []models.ChannelCursor if err := ar.ReadEntityJSON("channel_cursors", &cursors); err == nil && len(cursors) > 0 { if remapUserID { for i := range cursors { if cursors[i].UserID == sourceUserID { cursors[i].UserID = userID } } } importEntity("channel_cursors", func() (int, int, error) { return h.stores.Export.ImportChannelCursors(ctx, cursors) }) } // 6. Messages (ordered by created_at ASC for parent_id integrity) var messages []models.Message if err := ar.ReadEntityJSON("messages", &messages); err == nil && len(messages) > 0 { if remapUserID { for i := range messages { if messages[i].ParticipantType == "user" && messages[i].ParticipantID == sourceUserID { messages[i].ParticipantID = userID } } } importEntity("messages", func() (int, int, error) { return h.stores.Export.ImportMessages(ctx, messages) }) } // 7. Notes var notes []models.Note if err := ar.ReadEntityJSON("notes", ¬es); err == nil && len(notes) > 0 { if remapUserID { for i := range notes { if notes[i].UserID == sourceUserID { notes[i].UserID = userID } } } importEntity("notes", func() (int, int, error) { return h.stores.Export.ImportNotes(ctx, notes) }) } // 8. Note links var noteLinks []models.ExportNoteLink if err := ar.ReadEntityJSON("note_links", ¬eLinks); err == nil && len(noteLinks) > 0 { importEntity("note_links", func() (int, int, error) { return h.stores.Export.ImportNoteLinks(ctx, noteLinks) }) } // 9. Memories var memories []models.Memory if err := ar.ReadEntityJSON("memories", &memories); err == nil && len(memories) > 0 { if remapUserID { for i := range memories { if memories[i].OwnerID == sourceUserID { memories[i].OwnerID = userID } } } importEntity("memories", func() (int, int, error) { return h.stores.Export.ImportMemories(ctx, memories) }) } // 10. Project channels var projectChannels []models.ProjectChannel if err := ar.ReadEntityJSON("project_channels", &projectChannels); err == nil && len(projectChannels) > 0 { importEntity("project_channels", func() (int, int, error) { return h.stores.Export.ImportProjectChannels(ctx, projectChannels) }) } // 10b. Project KBs var projectKBs []models.ProjectKB if err := ar.ReadEntityJSON("project_knowledge_bases", &projectKBs); err == nil && len(projectKBs) > 0 { importEntity("project_knowledge_bases", func() (int, int, error) { return h.stores.Export.ImportProjectKBs(ctx, projectKBs) }) } // 10c. Project notes var projectNotes []models.ProjectNote if err := ar.ReadEntityJSON("project_notes", &projectNotes); err == nil && len(projectNotes) > 0 { importEntity("project_notes", func() (int, int, error) { return h.stores.Export.ImportProjectNotes(ctx, projectNotes) }) } // 11. Workspace files var workspaceFiles []models.WorkspaceFile if err := ar.ReadEntityJSON("workspace_files", &workspaceFiles); err == nil && len(workspaceFiles) > 0 { importEntity("workspace_files", func() (int, int, error) { return h.stores.Export.ImportWorkspaceFiles(ctx, workspaceFiles) }) } // 12. Files (metadata) var files []models.File if err := ar.ReadEntityJSON("files", &files); err == nil && len(files) > 0 { if remapUserID { for i := range files { if files[i].UserID == sourceUserID { files[i].UserID = userID } } } // Generate storage keys for imported files for i := range files { if files[i].StorageKey == "" && files[i].ChannelID != "" { files[i].StorageKey = fmt.Sprintf("files/%s/%s_%s", files[i].ChannelID, files[i].ID, files[i].Filename) } } importEntity("files", func() (int, int, error) { return h.stores.Export.ImportFiles(ctx, files) }) } // 12b. File blobs from archive if h.objStore != nil { fileBlobs := ar.FileEntries() blobCount := 0 for _, entry := range fileBlobs { if blobCount >= export.MaxExportFiles { errors = append(errors, "file blob limit reached, some files skipped") break } // Extract file ID from path: files/{fileID}/{filename} parts := strings.SplitN(strings.TrimPrefix(entry.Name, "files/"), "/", 2) if len(parts) != 2 { continue } fileID := parts[0] filename := parts[1] // Find matching file record for storage key storageKey := "" var contentType string for _, f := range files { if f.ID == fileID { storageKey = f.StorageKey contentType = f.ContentType break } } if storageKey == "" { storageKey = fmt.Sprintf("files/imported/%s_%s", fileID, filename) } if contentType == "" { contentType = "application/octet-stream" } rc, err := entry.Open() if err != nil { errors = append(errors, fmt.Sprintf("file %s: open error", filepath.Base(entry.Name))) continue } if err := h.objStore.Put(ctx, storageKey, rc, int64(entry.UncompressedSize64), contentType); err != nil { rc.Close() errors = append(errors, fmt.Sprintf("file %s: upload error", filepath.Base(entry.Name))) continue } rc.Close() blobCount++ } if blobCount > 0 { imported["file_blobs"] = blobCount } } // 13. Settings/Prefs var userModelSettings []models.UserModelSetting if err := ar.ReadEntityJSON("user_settings", &userModelSettings); err == nil && len(userModelSettings) > 0 { if remapUserID { for i := range userModelSettings { if userModelSettings[i].UserID == sourceUserID { userModelSettings[i].UserID = userID } } } importEntity("user_settings", func() (int, int, error) { return h.stores.Export.ImportUserModelSettings(ctx, userModelSettings) }) } var notifPrefs []models.NotificationPreference if err := ar.ReadEntityJSON("notification_preferences", ¬ifPrefs); err == nil && len(notifPrefs) > 0 { if remapUserID { for i := range notifPrefs { if notifPrefs[i].UserID == sourceUserID { notifPrefs[i].UserID = userID } } } importEntity("notification_preferences", func() (int, int, error) { return h.stores.Export.ImportNotifPrefs(ctx, notifPrefs) }) } // 14. Persona groups var personaGroups []models.PersonaGroup if err := ar.ReadEntityJSON("persona_groups", &personaGroups); err == nil && len(personaGroups) > 0 { if remapUserID { for i := range personaGroups { if personaGroups[i].OwnerID == sourceUserID { personaGroups[i].OwnerID = userID } } } importEntity("persona_groups", func() (int, int, error) { return h.stores.Export.ImportPersonaGroups(ctx, personaGroups) }) } var personaGroupMembers []models.PersonaGroupMember if err := ar.ReadEntityJSON("persona_group_members", &personaGroupMembers); err == nil && len(personaGroupMembers) > 0 { importEntity("persona_group_members", func() (int, int, error) { return h.stores.Export.ImportPersonaGroupMembers(ctx, personaGroupMembers) }) } // ── Audit log ── h.stores.Audit.Log(ctx, &models.AuditEntry{ ActorID: &userID, Action: "user.import", ResourceType: "user", ResourceID: userID, Metadata: models.JSONMap{"imported": imported, "skipped": skipped, "errors": errors}, }) c.JSON(http.StatusOK, gin.H{ "imported": imported, "skipped": skipped, "errors": errors, }) } // ImportTeam imports team data from a .switchboard archive. // POST /api/v1/admin/teams/:id/import func (h *DataImportHandler) ImportTeam(c *gin.Context) { ctx := c.Request.Context() userID := c.GetString("user_id") teamID := c.Param("id") // Verify team exists team, err := h.stores.Teams.GetByID(ctx, teamID) if err != nil || team == nil { c.JSON(http.StatusNotFound, gin.H{"error": "team not found"}) return } // Receive upload file, header, err := c.Request.FormFile("file") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "missing file field"}) return } defer file.Close() if header.Size > export.MaxExportArchiveSize { c.JSON(http.StatusBadRequest, gin.H{ "error": fmt.Sprintf("archive too large (max %d MB)", export.MaxExportArchiveSize/(1024*1024)), }) return } tmp, err := os.CreateTemp("", "switchboard-team-import-*.zip") if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"}) return } defer os.Remove(tmp.Name()) defer tmp.Close() if _, err := io.Copy(tmp, file); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save upload"}) return } tmp.Close() ar, err := export.OpenArchive(tmp.Name()) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid archive: " + err.Error()}) return } defer ar.Close() manifest, err := ar.ReadManifest() if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid manifest: " + err.Error()}) return } if manifest.FormatVersion > export.FormatVersion { c.JSON(http.StatusBadRequest, gin.H{ "error": fmt.Sprintf("unsupported format version %d", manifest.FormatVersion), }) return } imported := make(map[string]int) skipped := make(map[string]int) var errors []string importEntity := func(name string, fn func() (int, int, error)) { imp, skip, err := fn() if err != nil { errors = append(errors, fmt.Sprintf("%s: %v", name, err)) slog.Error("import: team entity error", "entity", name, "error", err) } if imp > 0 { imported[name] = imp } if skip > 0 { skipped[name] = skip } } // Remap team_id on entities to target team sourceTeamID := "" if manifest.Scope != nil { sourceTeamID = manifest.Scope.TeamID } remapTeam := sourceTeamID != "" && sourceTeamID != teamID // Channels (remap team_id) var channels []models.Channel if err := ar.ReadEntityJSON("channels", &channels); err == nil && len(channels) > 0 { if remapTeam { for i := range channels { if channels[i].TeamID != nil && *channels[i].TeamID == sourceTeamID { channels[i].TeamID = &teamID } } } importEntity("channels", func() (int, int, error) { return h.stores.Export.ImportChannels(ctx, channels) }) } // Messages var messages []models.Message if err := ar.ReadEntityJSON("messages", &messages); err == nil && len(messages) > 0 { importEntity("messages", func() (int, int, error) { return h.stores.Export.ImportMessages(ctx, messages) }) } // Projects (remap team_id) var projects []models.Project if err := ar.ReadEntityJSON("projects", &projects); err == nil && len(projects) > 0 { if remapTeam { for i := range projects { if projects[i].TeamID != nil && *projects[i].TeamID == sourceTeamID { projects[i].TeamID = &teamID } } } importEntity("projects", func() (int, int, error) { return h.stores.Export.ImportProjects(ctx, projects) }) } // Persona groups (remap team_id) var personaGroups []models.PersonaGroup if err := ar.ReadEntityJSON("persona_groups", &personaGroups); err == nil && len(personaGroups) > 0 { if remapTeam { for i := range personaGroups { if personaGroups[i].TeamID != nil && *personaGroups[i].TeamID == sourceTeamID { personaGroups[i].TeamID = &teamID } } } importEntity("persona_groups", func() (int, int, error) { return h.stores.Export.ImportPersonaGroups(ctx, personaGroups) }) } var personaGroupMembers []models.PersonaGroupMember if err := ar.ReadEntityJSON("persona_group_members", &personaGroupMembers); err == nil && len(personaGroupMembers) > 0 { importEntity("persona_group_members", func() (int, int, error) { return h.stores.Export.ImportPersonaGroupMembers(ctx, personaGroupMembers) }) } // Audit log h.stores.Audit.Log(ctx, &models.AuditEntry{ ActorID: &userID, Action: "team.import", ResourceType: "team", ResourceID: teamID, Metadata: models.JSONMap{"imported": imported, "skipped": skipped, "errors": errors}, }) c.JSON(http.StatusOK, gin.H{ "imported": imported, "skipped": skipped, "errors": errors, }) } // ImportChatGPT imports conversations from a ChatGPT export. // POST /api/v1/import/chatgpt func (h *DataImportHandler) ImportChatGPT(c *gin.Context) { ctx := c.Request.Context() userID := c.GetString("user_id") file, header, err := c.Request.FormFile("file") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "missing file field"}) return } defer file.Close() if header.Size > export.MaxExportArchiveSize { c.JSON(http.StatusBadRequest, gin.H{ "error": fmt.Sprintf("file too large (max %d MB)", export.MaxExportArchiveSize/(1024*1024)), }) return } // Detect if it's a zip or raw JSON var convs export.ChatGPTExport if strings.HasSuffix(header.Filename, ".zip") { // Save to temp and extract conversations.json tmp, err := os.CreateTemp("", "chatgpt-import-*.zip") if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"}) return } defer os.Remove(tmp.Name()) defer tmp.Close() if _, err := io.Copy(tmp, file); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save upload"}) return } tmp.Close() ar, err := export.OpenArchive(tmp.Name()) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid zip archive"}) return } defer ar.Close() // Try to find conversations.json in the zip if err := ar.ReadEntityJSON("conversations", &convs); err != nil || len(convs) == 0 { // Try searching all files for conversations.json for _, f := range ar.FileEntries() { if strings.HasSuffix(f.Name, "conversations.json") { rc, err := f.Open() if err != nil { continue } convs, err = export.ParseChatGPTExport(rc) rc.Close() if err == nil { break } } } } } else { // Raw JSON file convs, err = export.ParseChatGPTExport(file) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "failed to parse conversations.json: " + err.Error()}) return } } if len(convs) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "no conversations found in upload"}) return } // Convert to Switchboard models result := export.ConvertChatGPTExport(convs, userID) // Import channels and messages chImported, chSkipped, err := h.stores.Export.ImportChannels(ctx, result.Channels) if err != nil { slog.Error("chatgpt import: channels", "error", err) } msgImported, msgSkipped, err := h.stores.Export.ImportMessages(ctx, result.Messages) if err != nil { slog.Error("chatgpt import: messages", "error", err) } imported := map[string]int{ "channels": chImported, "messages": msgImported, } skipped := map[string]int{ "channels": chSkipped, "messages": msgSkipped, } h.stores.Audit.Log(ctx, &models.AuditEntry{ ActorID: &userID, Action: "user.import_chatgpt", ResourceType: "user", ResourceID: userID, Metadata: models.JSONMap{ "conversations": len(convs), "imported": imported, "skipped": skipped, }, }) c.JSON(http.StatusOK, gin.H{ "conversations": len(convs), "imported": imported, "skipped": skipped, }) }