feat(crm): persist contact export artifacts

This commit is contained in:
2026-06-05 03:38:16 +08:00
parent 861aea1f38
commit 47c2b805e9
13 changed files with 591 additions and 53 deletions
@@ -1,6 +1,7 @@
package v1
import (
"io"
"net/http"
"strconv"
@@ -706,9 +707,41 @@ func (h *ContactHandler) ExportRequest(c *gin.Context) {
return
}
var req service.ContactExportRequest
if err := c.ShouldBindJSON(&req); err != nil && err != io.EOF {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to export contacts"})
return
}
if _, svcErr := h.svc.ExportContacts(c.Request.Context(), accountID, getUserID(c), req); svcErr != nil {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to export contacts"})
return
}
c.Status(http.StatusOK)
}
// DownloadExport streams a previously generated contacts export artifact.
func (h *ContactHandler) DownloadExport(c *gin.Context) {
accountID := parseAccountIDParam(c)
if accountID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"})
return
}
exportID, err := parseUintParam(c, "export_id")
if err != nil || exportID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid export id"})
return
}
export, svcErr := h.svc.GetContactExport(c.Request.Context(), accountID, exportID)
if svcErr != nil || len(export.CSVData) == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "export not found"})
return
}
c.Header("Content-Disposition", "attachment; filename="+export.FileName)
c.Data(http.StatusOK, export.ContentType, export.CSVData)
}
// Import uploads contacts from a CSV file.
// POST /api/v1/accounts/:id/contacts/import
// Reference: Chatwoot contacts#import
@@ -52,7 +52,9 @@ func (s *ContactHandlerCRUDTestSuite) SetupSuite() {
&model.Contact{},
&model.Tag{},
&model.ContactLabel{},
&model.ContactExport{},
&model.DataImport{},
&model.Notification{},
&model.Conversation{},
&model.Message{},
&model.ContactInbox{},
@@ -84,6 +86,8 @@ func (s *ContactHandlerCRUDTestSuite) SetupSuite() {
s.router.Use(gin.Recovery(), s.mockAuthMiddleware())
s.router.GET("/api/v1/accounts/:id/contacts", s.handler.List)
s.router.GET("/api/v1/accounts/:id/contacts/search", s.handler.Search)
s.router.POST("/api/v1/accounts/:id/contacts/export", s.handler.ExportRequest)
s.router.GET("/api/v1/accounts/:id/contacts/export/:export_id/download", s.handler.DownloadExport)
s.router.POST("/api/v1/accounts/:id/contacts/import", s.handler.Import)
s.router.GET("/api/v1/accounts/:id/contacts/:contact_id", s.handler.Get)
s.router.POST("/api/v1/accounts/:id/contacts", s.handler.Create)
@@ -123,7 +127,9 @@ func (s *ContactHandlerCRUDTestSuite) SetupTest() {
s.db.Exec("DELETE FROM contact_notes")
s.db.Exec("DELETE FROM contact_labels")
s.db.Exec("DELETE FROM tags")
s.db.Exec("DELETE FROM contact_exports")
s.db.Exec("DELETE FROM data_imports")
s.db.Exec("DELETE FROM notifications")
s.db.Exec("DELETE FROM messages")
s.db.Exec("DELETE FROM notes")
s.db.Exec("DELETE FROM contact_inboxes")
@@ -635,6 +641,63 @@ func (s *ContactHandlerCRUDTestSuite) TestImport_CreatesDataImportAndReturnsOK()
s.Equal(int64(1), labelCount)
}
func (s *ContactHandlerCRUDTestSuite) TestExportRequest_CreatesArtifactNotificationAndReturnsOK() {
contact := &model.Contact{AccountID: s.account.ID, Name: "Exported", Email: "exported@example.com"}
s.Require().NoError(s.db.Create(contact).Error)
tag := &model.Tag{AccountID: s.account.ID, Name: "vip"}
s.Require().NoError(s.db.Create(tag).Error)
s.Require().NoError(s.db.Create(&model.ContactLabel{AccountID: s.account.ID, ContactID: contact.ID, TagID: tag.ID}).Error)
bodyBytes, _ := json.Marshal(map[string]any{
"column_names": []string{"email", "labels"},
"label": "vip",
})
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST",
fmt.Sprintf("/api/v1/accounts/%d/contacts/export", s.account.ID),
bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
s.Equal(http.StatusOK, w.Code)
s.Empty(w.Body.String())
var export model.ContactExport
s.Require().NoError(s.db.Where("account_id = ?", s.account.ID).First(&export).Error)
s.Equal(string(model.DataImportStatusCompleted), export.Status)
s.Equal(1, export.RowCount)
s.Contains(string(export.CSVData), "email,labels")
s.Contains(string(export.CSVData), "exported@example.com,vip")
s.Contains(export.FileURL, fmt.Sprintf("/contacts/export/%d/download", export.ID))
var notification model.Notification
s.Require().NoError(s.db.Where("user_id = ? AND notification_type = ?", s.user.ID, "contacts_export_complete").First(&notification).Error)
s.Equal("ContactExport", notification.PrimaryActorType)
s.Equal(export.ID, notification.PrimaryActorID)
}
func (s *ContactHandlerCRUDTestSuite) TestDownloadExport_ReturnsPersistedCSVArtifact() {
export := &model.ContactExport{
AccountID: s.account.ID,
UserID: &s.user.ID,
Status: string(model.DataImportStatusCompleted),
FileName: "contacts.csv",
ContentType: "text/csv",
CSVData: []byte("\ufeffemail\nexported@example.com\n"),
}
s.Require().NoError(s.db.Create(export).Error)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET",
fmt.Sprintf("/api/v1/accounts/%d/contacts/export/%d/download", s.account.ID, export.ID), nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusOK, w.Code)
s.Equal("text/csv", w.Header().Get("Content-Type"))
s.Contains(w.Header().Get("Content-Disposition"), "contacts.csv")
s.Contains(w.Body.String(), "exported@example.com")
}
func (s *ContactHandlerCRUDTestSuite) TestLabels_UpdateListAndFilter() {
bodyBytes, _ := json.Marshal(map[string]interface{}{"labels": []string{"vip", "trial"}})