diff --git a/internal/handler/chat.go b/internal/handler/chat.go index 186763cbcc..e223d09bc8 100644 --- a/internal/handler/chat.go +++ b/internal/handler/chat.go @@ -282,6 +282,135 @@ func (h *ChatHandler) RemoveChats(c *gin.Context) { }) } +// DeleteChat soft deletes a chat by ID. +func (h *ChatHandler) DeleteChat(c *gin.Context) { + user, errorCode, errorMessage := GetUser(c) + if errorCode != common.CodeSuccess { + jsonError(c, errorCode, errorMessage) + return + } + userID := user.ID + + chatID := c.Param("chat_id") + if chatID == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "code": common.CodeBadRequest, + "data": nil, + "message": "chat_id is required", + }) + return + } + + if err := h.chatService.DeleteChat(userID, chatID); err != nil { + if err.Error() == "no authorization" { + c.JSON(http.StatusOK, gin.H{ + "code": common.CodeAuthenticationError, + "data": false, + "message": "No authorization.", + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "code": common.CodeDataError, + "data": nil, + "message": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "code": common.CodeSuccess, + "data": true, + "message": "success", + }) +} + +// BulkDeleteChats soft deletes multiple chats owned by the current user. +func (h *ChatHandler) BulkDeleteChats(c *gin.Context) { + user, errorCode, errorMessage := GetUser(c) + if errorCode != common.CodeSuccess { + jsonError(c, errorCode, errorMessage) + return + } + userID := user.ID + if c.Request.Body == nil || c.Request.ContentLength == 0 { + c.JSON(http.StatusOK, gin.H{ + "code": common.CodeSuccess, + "data": map[string]interface{}{}, + "message": "success", + }) + return + } + + var req service.BulkDeleteChatsRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "code": common.CodeBadRequest, + "data": nil, + "message": "Invalid request body: " + err.Error(), + }) + return + } + + if len(req.IDs) == 0 && !req.DeleteAll { + if req.ChatID != "" { + if err := h.chatService.DeleteChat(userID, req.ChatID); err != nil { + if err.Error() == "no authorization" { + c.JSON(http.StatusOK, gin.H{ + "code": common.CodeAuthenticationError, + "data": false, + "message": "No authorization.", + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "code": common.CodeDataError, + "data": nil, + "message": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "code": common.CodeSuccess, + "data": true, + "message": "success", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "code": common.CodeSuccess, + "data": map[string]interface{}{}, + "message": "success", + }) + return + } + + result, err := h.chatService.BulkDeleteChats(userID, &req) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "code": common.CodeDataError, + "data": nil, + "message": err.Error(), + }) + return + } + + message := "success" + if errorsList, ok := result["errors"].([]string); ok && len(errorsList) > 0 { + if successCount, ok := result["success_count"].(int); ok { + message = "Partially deleted " + strconv.Itoa(successCount) + " chats with " + strconv.Itoa(len(errorsList)) + " errors" + } + } + + c.JSON(http.StatusOK, gin.H{ + "code": common.CodeSuccess, + "data": result, + "message": message, + }) +} + // GetChat get chat detail // @Summary Get Chat Detail // @Description Get detail of a chat by ID diff --git a/internal/handler/chat_test.go b/internal/handler/chat_test.go new file mode 100644 index 0000000000..bbe75366c0 --- /dev/null +++ b/internal/handler/chat_test.go @@ -0,0 +1,122 @@ +package handler + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "gorm.io/gorm" + + "ragflow/internal/common" + "ragflow/internal/dao" + "ragflow/internal/entity" + "ragflow/internal/service" +) + +func setupChatHandlerTestDB(t *testing.T) *gorm.DB { + t.Helper() + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ + TranslateError: true, + }) + if err != nil { + t.Fatalf("failed to open sqlite: %v", err) + } + + if err := db.AutoMigrate(&entity.Chat{}); err != nil { + t.Fatalf("failed to migrate test schema: %v", err) + } + + origDB := dao.DB + dao.DB = db + t.Cleanup(func() { dao.DB = origDB }) + + return db +} + +func createChatHandlerTestChat(t *testing.T, db *gorm.DB, id, tenantID string) { + t.Helper() + + name := "chat-" + id + status := string(entity.StatusValid) + chat := &entity.Chat{ + ID: id, + TenantID: tenantID, + Name: &name, + LLMID: "model-a", + LLMSetting: entity.JSONMap{}, + PromptType: "simple", + PromptConfig: entity.JSONMap{"system": "sys"}, + KBIDs: entity.JSONSlice{}, + Status: &status, + } + if err := db.Create(chat).Error; err != nil { + t.Fatalf("failed to create chat: %v", err) + } +} + +func TestDeleteChatHandlerSuccess(t *testing.T) { + db := setupChatHandlerTestDB(t) + createChatHandlerTestChat(t, db, "chat-1", "user-1") + + h := NewChatHandler(service.NewChatService(), service.NewUserService()) + c, w := setupGinContextWithUser("DELETE", "/api/v1/chats/chat-1", "") + c.Params = []gin.Param{{Key: "chat_id", Value: "chat-1"}} + + h.DeleteChat(c) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if resp["code"] != float64(common.CodeSuccess) { + t.Fatalf("expected success code, got %v", resp["code"]) + } + if resp["data"] != true { + t.Fatalf("expected data=true, got %v", resp["data"]) + } +} + +func TestBulkDeleteChatsHandlerPartialSuccess(t *testing.T) { + db := setupChatHandlerTestDB(t) + createChatHandlerTestChat(t, db, "chat-1", "user-1") + createChatHandlerTestChat(t, db, "chat-2", "tenant-2") + + h := NewChatHandler(service.NewChatService(), service.NewUserService()) + c, w := setupGinContextWithUser("DELETE", "/api/v1/chats", `{"ids":["chat-1","chat-2","chat-1"]}`) + + h.BulkDeleteChats(c) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if resp["code"] != float64(common.CodeSuccess) { + t.Fatalf("expected success code, got %v", resp["code"]) + } + + data, ok := resp["data"].(map[string]interface{}) + if !ok { + t.Fatalf("expected object data, got %+v", resp["data"]) + } + if data["success_count"] != float64(1) { + t.Fatalf("expected success_count=1, got %v", data["success_count"]) + } + errorsList, ok := data["errors"].([]interface{}) + if !ok || len(errorsList) != 2 { + t.Fatalf("expected 2 errors, got %+v", data["errors"]) + } + if resp["message"] != "Partially deleted 1 chats with 2 errors" { + t.Fatalf("unexpected message: %v", resp["message"]) + } +} diff --git a/internal/router/router.go b/internal/router/router.go index c77263701b..a9f73457a1 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -244,6 +244,8 @@ func (r *Router) Setup(engine *gin.Engine) { chats := v1.Group("/chats") { chats.GET("", r.chatHandler.ListChats) + chats.DELETE("", r.chatHandler.BulkDeleteChats) + chats.DELETE("/:chat_id", r.chatHandler.DeleteChat) chats.GET("/:chat_id", r.chatHandler.GetChat) chats.GET("/:chat_id/sessions", r.chatSessionHandler.ListChatSessions) } diff --git a/internal/service/chat.go b/internal/service/chat.go index 4e8915a3cc..b11ba48dc6 100644 --- a/internal/service/chat.go +++ b/internal/service/chat.go @@ -644,6 +644,110 @@ func getEmbdIDs(kbs []*entity.Knowledgebase) []string { return ids } +func (s *ChatService) getOwnedValidChat(userID, chatID string) (*entity.Chat, error) { + chat, err := s.chatDAO.GetByIDAndStatus(chatID, string(entity.StatusValid)) + if err != nil { + return nil, errors.New("no authorization") + } + if chat.TenantID != userID { + return nil, errors.New("no authorization") + } + return chat, nil +} + +// DeleteChat soft deletes a single chat owned by the current user. +func (s *ChatService) DeleteChat(userID, chatID string) error { + if _, err := s.getOwnedValidChat(userID, chatID); err != nil { + return err + } + if err := s.chatDAO.UpdateByID(chatID, map[string]interface{}{ + "status": string(entity.StatusInvalid), + }); err != nil { + return fmt.Errorf("Failed to delete chat %s", chatID) + } + + return nil +} + +// BulkDeleteChatsRequest matches DELETE /api/v1/chats request semantics. +type BulkDeleteChatsRequest struct { + IDs []string `json:"ids,omitempty"` + DeleteAll bool `json:"delete_all,omitempty"` + ChatID string `json:"chat_id,omitempty"` +} + +// checkDuplicateChatIDs +func checkDuplicateChatIDs(ids []string) ([]string, []string) { + idCount := make(map[string]int, len(ids)) + uniqueIDs := make([]string, 0, len(ids)) + for _, id := range ids { + id = strings.TrimSpace(id) + if id == "" { + continue + } + idCount[id]++ + if idCount[id] == 1 { + uniqueIDs = append(uniqueIDs, id) + } + } + + duplicateMessages := make([]string, 0) + for id, count := range idCount { + if count > 1 { + duplicateMessages = append(duplicateMessages, fmt.Sprintf("Duplicate chat ids: %s", id)) + } + } + return uniqueIDs, duplicateMessages +} + +// BulkDeleteChats soft deletes chats owned by the current user with partial success semantics. +func (s *ChatService) BulkDeleteChats(userID string, req *BulkDeleteChatsRequest) (map[string]interface{}, error) { + ids := req.IDs + if len(ids) == 0 && req.DeleteAll { + chats, err := s.chatDAO.ListByTenantID(userID, string(entity.StatusValid)) + if err != nil { + return nil, err + } + for _, chat := range chats { + ids = append(ids, chat.ID) + } + if len(ids) == 0 { + return map[string]interface{}{}, nil + } + } + + uniqueIDs, duplicateMessages := checkDuplicateChatIDs(ids) + errorsList := make([]string, 0, len(duplicateMessages)) + errorsList = append(errorsList, duplicateMessages...) + successCount := 0 + + for _, chatID := range uniqueIDs { + if _, err := s.getOwnedValidChat(userID, chatID); err != nil { + errorsList = append(errorsList, fmt.Sprintf("Chat(%s) not found.", chatID)) + continue + } + if err := s.chatDAO.UpdateByID(chatID, map[string]interface{}{ + "status": string(entity.StatusInvalid), + }); err != nil { + errorsList = append(errorsList, fmt.Sprintf("Failed to delete chat %s", chatID)) + continue + } + successCount++ + } + + if len(errorsList) == 0 { + return map[string]interface{}{"success_count": successCount}, nil + } + if successCount > 0 { + return map[string]interface{}{ + "success_count": successCount, + "errors": errorsList, + }, nil + } + + return nil, errors.New(strings.Join(errorsList, "; ")) +} + // RemoveChats removes dialogs by setting their status to invalid (soft delete) // Only the owner of the chat can perform this operation func (s *ChatService) RemoveChats(userID string, chatIDs []string) error { diff --git a/internal/service/chat_delete_test.go b/internal/service/chat_delete_test.go new file mode 100644 index 0000000000..6a0b70c1fc --- /dev/null +++ b/internal/service/chat_delete_test.go @@ -0,0 +1,146 @@ +package service + +import ( + "strings" + "testing" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + + "ragflow/internal/dao" + "ragflow/internal/entity" +) + +func setupChatDeleteServiceTestDB(t *testing.T) *gorm.DB { + t.Helper() + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ + TranslateError: true, + }) + if err != nil { + t.Fatalf("failed to open sqlite: %v", err) + } + + if err := db.AutoMigrate(&entity.Chat{}); err != nil { + t.Fatalf("failed to migrate test schema: %v", err) + } + + origDB := dao.DB + dao.DB = db + t.Cleanup(func() { + dao.DB = origDB + }) + + return db +} + +func createChatDeleteServiceTestChat(t *testing.T, db *gorm.DB, id, tenantID string) { + t.Helper() + + name := "chat-" + id + status := string(entity.StatusValid) + chat := &entity.Chat{ + ID: id, + TenantID: tenantID, + Name: &name, + LLMID: "model-a", + LLMSetting: entity.JSONMap{}, + PromptType: "simple", + PromptConfig: entity.JSONMap{"system": "sys"}, + KBIDs: entity.JSONSlice{}, + Status: &status, + } + if err := db.Create(chat).Error; err != nil { + t.Fatalf("failed to create chat: %v", err) + } +} + +func TestChatServiceDeleteChatRejectsNonOwner(t *testing.T) { + db := setupChatDeleteServiceTestDB(t) + createChatDeleteServiceTestChat(t, db, "chat-1", "tenant-1") + + svc := NewChatService() + err := svc.DeleteChat("user-1", "chat-1") + if err == nil || err.Error() != "no authorization" { + t.Fatalf("expected no authorization, got %v", err) + } + + chat, getErr := svc.chatDAO.GetByID("chat-1") + if getErr != nil { + t.Fatalf("failed to fetch chat: %v", getErr) + } + if chat.Status == nil || *chat.Status != string(entity.StatusValid) { + t.Fatalf("expected chat status to remain valid, got %+v", chat.Status) + } +} + +func TestChatServiceBulkDeleteChatsDeleteAllOnlyDeletesOwnedChats(t *testing.T) { + db := setupChatDeleteServiceTestDB(t) + createChatDeleteServiceTestChat(t, db, "chat-1", "user-1") + createChatDeleteServiceTestChat(t, db, "chat-2", "user-1") + createChatDeleteServiceTestChat(t, db, "chat-3", "tenant-2") + + svc := NewChatService() + result, err := svc.BulkDeleteChats("user-1", &BulkDeleteChatsRequest{DeleteAll: true}) + if err != nil { + t.Fatalf("BulkDeleteChats failed: %v", err) + } + + if got, ok := result["success_count"].(int); !ok || got != 2 { + t.Fatalf("expected success_count 2, got %+v", result["success_count"]) + } + + owned1, err := svc.chatDAO.GetByID("chat-1") + if err != nil { + t.Fatalf("failed to fetch chat-1: %v", err) + } + if owned1.Status == nil || *owned1.Status != string(entity.StatusInvalid) { + t.Fatalf("expected chat-1 invalid, got %+v", owned1.Status) + } + + owned2, err := svc.chatDAO.GetByID("chat-2") + if err != nil { + t.Fatalf("failed to fetch chat-2: %v", err) + } + if owned2.Status == nil || *owned2.Status != string(entity.StatusInvalid) { + t.Fatalf("expected chat-2 invalid, got %+v", owned2.Status) + } + + other, err := svc.chatDAO.GetByID("chat-3") + if err != nil { + t.Fatalf("failed to fetch chat-3: %v", err) + } + if other.Status == nil || *other.Status != string(entity.StatusValid) { + t.Fatalf("expected chat-3 to remain valid, got %+v", other.Status) + } +} + +func TestChatServiceBulkDeleteChatsReturnsPartialSuccessErrors(t *testing.T) { + db := setupChatDeleteServiceTestDB(t) + createChatDeleteServiceTestChat(t, db, "chat-1", "user-1") + createChatDeleteServiceTestChat(t, db, "chat-2", "tenant-2") + + svc := NewChatService() + result, err := svc.BulkDeleteChats("user-1", &BulkDeleteChatsRequest{ + IDs: []string{"chat-1", "chat-2", "chat-1"}, + }) + if err != nil { + t.Fatalf("BulkDeleteChats failed: %v", err) + } + + if got, ok := result["success_count"].(int); !ok || got != 1 { + t.Fatalf("expected success_count 1, got %+v", result["success_count"]) + } + + errorsList, ok := result["errors"].([]string) + if !ok { + t.Fatalf("expected errors list, got %+v", result["errors"]) + } + joined := strings.Join(errorsList, " | ") + if !strings.Contains(joined, "Duplicate chat ids: chat-1") { + t.Fatalf("expected duplicate id error, got %v", errorsList) + } + if !strings.Contains(joined, "Chat(chat-2) not found.") { + t.Fatalf("expected not found error, got %v", errorsList) + } +}