feat(crm): complete contact label avatar gaps
This commit is contained in:
@@ -175,6 +175,55 @@ func (h *CompanyHandler) Delete(c *gin.Context) {
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
func (h *CompanyHandler) DestroyCustomAttributes(c *gin.Context) {
|
||||
accountID := getAccountID(c)
|
||||
if accountID == 0 {
|
||||
response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "account not identified")
|
||||
return
|
||||
}
|
||||
companyID, err := strconv.ParseUint(c.Param("company_id"), 10, 32)
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid company id")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
CustomAttributes []string `json:"custom_attributes"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.CustomAttributes == nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "custom_attributes must be an array"})
|
||||
return
|
||||
}
|
||||
company, svcErr := h.svc.DestroyCustomAttributes(c.Request.Context(), uint(companyID), accountID, req.CustomAttributes)
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, companyPayloadResponse(c.Request.Context(), h.svc.DB(), company))
|
||||
}
|
||||
|
||||
func (h *CompanyHandler) DeleteAvatar(c *gin.Context) {
|
||||
accountID := getAccountID(c)
|
||||
if accountID == 0 {
|
||||
response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "account not identified")
|
||||
return
|
||||
}
|
||||
companyID, err := strconv.ParseUint(c.Param("company_id"), 10, 32)
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid company id")
|
||||
return
|
||||
}
|
||||
company, svcErr := h.svc.DeleteAvatar(c.Request.Context(), uint(companyID), accountID)
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, companyPayloadResponse(c.Request.Context(), h.svc.DB(), company))
|
||||
}
|
||||
|
||||
// ListContacts retrieves contacts associated with a company.
|
||||
// GET /api/v1/accounts/:id/companies/:company_id/contacts?page=1&per_page=25
|
||||
func (h *CompanyHandler) ListContacts(c *gin.Context) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
@@ -103,6 +104,8 @@ func (s *CompanyHandlerTestSuite) SetupSuite() {
|
||||
companies.PUT("/:company_id", s.handler.Update)
|
||||
companies.PATCH("/:company_id", s.handler.Update)
|
||||
companies.DELETE("/:company_id", s.handler.Delete)
|
||||
companies.POST("/:company_id/destroy_custom_attributes", s.handler.DestroyCustomAttributes)
|
||||
companies.DELETE("/:company_id/avatar", s.handler.DeleteAvatar)
|
||||
companies.GET("/:company_id/contacts", s.handler.ListContacts)
|
||||
companies.GET("/:company_id/contacts/search", s.handler.SearchContacts)
|
||||
companies.POST("/:company_id/contacts", s.handler.AddContact)
|
||||
@@ -292,6 +295,55 @@ func (s *CompanyHandlerTestSuite) TestDelete_InvalidID() {
|
||||
"Expected 400 or 404 for invalid company ID, got %d", w.Code)
|
||||
}
|
||||
|
||||
func (s *CompanyHandlerTestSuite) TestDestroyCustomAttributes_Success() {
|
||||
companyRepo := repository.NewCompanyRepo(s.db)
|
||||
company := &model.Company{
|
||||
AccountID: s.accountID,
|
||||
Name: "AttrCorp",
|
||||
CustomAttributes: datatypes.JSON([]byte(`{"plan":"pro","tier":"gold"}`)),
|
||||
}
|
||||
s.Require().NoError(companyRepo.Create(context.Background(), company))
|
||||
|
||||
w := s.makeRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/companies/%d/destroy_custom_attributes", s.accountID, company.ID), map[string]interface{}{
|
||||
"custom_attributes": []string{"tier"},
|
||||
})
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
|
||||
var resp map[string]interface{}
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
payload := resp["payload"].(map[string]interface{})
|
||||
attrs := payload["custom_attributes"].(map[string]interface{})
|
||||
assert.Equal(s.T(), "pro", attrs["plan"])
|
||||
assert.NotContains(s.T(), attrs, "tier")
|
||||
}
|
||||
|
||||
func (s *CompanyHandlerTestSuite) TestDestroyCustomAttributes_RequiresArray() {
|
||||
companyRepo := repository.NewCompanyRepo(s.db)
|
||||
company := &model.Company{AccountID: s.accountID, Name: "AttrInvalidCorp"}
|
||||
s.Require().NoError(companyRepo.Create(context.Background(), company))
|
||||
|
||||
w := s.makeRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/companies/%d/destroy_custom_attributes", s.accountID, company.ID), map[string]interface{}{})
|
||||
assert.Equal(s.T(), http.StatusUnprocessableEntity, w.Code)
|
||||
}
|
||||
|
||||
func (s *CompanyHandlerTestSuite) TestDeleteAvatar_Success() {
|
||||
companyRepo := repository.NewCompanyRepo(s.db)
|
||||
company := &model.Company{AccountID: s.accountID, Name: "AvatarCorp", FaviconURL: "https://example.com/avatar.png"}
|
||||
s.Require().NoError(companyRepo.Create(context.Background(), company))
|
||||
|
||||
w := s.makeRequest("DELETE", fmt.Sprintf("/api/v1/accounts/%d/companies/%d/avatar", s.accountID, company.ID), nil)
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
|
||||
var resp map[string]interface{}
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
payload := resp["payload"].(map[string]interface{})
|
||||
assert.Equal(s.T(), "", payload["avatar_url"])
|
||||
|
||||
var found model.Company
|
||||
s.Require().NoError(s.db.First(&found, company.ID).Error)
|
||||
assert.Equal(s.T(), "", found.FaviconURL)
|
||||
}
|
||||
|
||||
// ========== ListContacts ==========
|
||||
|
||||
func (s *CompanyHandlerTestSuite) TestListContacts_Success() {
|
||||
|
||||
@@ -63,7 +63,7 @@ func (h *ContactHandler) List(c *gin.Context) {
|
||||
offset := (page - 1) * perPage
|
||||
sort := c.DefaultQuery("sort", "")
|
||||
|
||||
contacts, total, svcErr := h.svc.ListByAccount(c.Request.Context(), accountID, offset, perPage, sort)
|
||||
contacts, total, svcErr := h.svc.ListByAccount(c.Request.Context(), accountID, offset, perPage, sort, contactLabelsParam(c))
|
||||
if svcErr != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to list contacts"})
|
||||
return
|
||||
@@ -110,7 +110,7 @@ func (h *ContactHandler) Search(c *gin.Context) {
|
||||
sort := c.DefaultQuery("sort", "")
|
||||
searchMode := search.ParseSearchMode(c.DefaultQuery("search_mode", ""))
|
||||
|
||||
contacts, total, svcErr := h.svc.Search(c.Request.Context(), accountID, query, offset, perPage, sort, searchMode)
|
||||
contacts, total, svcErr := h.svc.Search(c.Request.Context(), accountID, query, offset, perPage, sort, searchMode, contactLabelsParam(c))
|
||||
if svcErr != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to search contacts"})
|
||||
return
|
||||
@@ -286,6 +286,70 @@ func (h *ContactHandler) Delete(c *gin.Context) {
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
func (h *ContactHandler) DeleteAvatar(c *gin.Context) {
|
||||
accountID := parseAccountIDParam(c)
|
||||
if accountID == 0 {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id")
|
||||
return
|
||||
}
|
||||
contactID, err := parseUintParam(c, "contact_id")
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid contact id")
|
||||
return
|
||||
}
|
||||
contact, svcErr := h.svc.DeleteAvatar(c.Request.Context(), accountID, contactID)
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, contactPayloadResponse(c.Request.Context(), h.svc.DB(), contact, false))
|
||||
}
|
||||
|
||||
func (h *ContactHandler) ListLabels(c *gin.Context) {
|
||||
accountID := parseAccountIDParam(c)
|
||||
if accountID == 0 {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id")
|
||||
return
|
||||
}
|
||||
contactID, err := parseUintParam(c, "contact_id")
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid contact id")
|
||||
return
|
||||
}
|
||||
labels, svcErr := h.svc.GetLabels(c.Request.Context(), accountID, contactID)
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"payload": labels})
|
||||
}
|
||||
|
||||
func (h *ContactHandler) UpdateLabels(c *gin.Context) {
|
||||
accountID := parseAccountIDParam(c)
|
||||
if accountID == 0 {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id")
|
||||
return
|
||||
}
|
||||
contactID, err := parseUintParam(c, "contact_id")
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid contact id")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Labels []string `json:"labels"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
|
||||
return
|
||||
}
|
||||
labels, svcErr := h.svc.UpdateLabels(c.Request.Context(), accountID, contactID, req.Labels)
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"payload": labels})
|
||||
}
|
||||
|
||||
// ListContactInboxes retrieves all contact_inboxes for a contact.
|
||||
// GET /api/v1/accounts/:id/contacts/:contact_id/contact_inboxes
|
||||
// Reference: Chatwoot contacts#contact_inboxes (nested resource)
|
||||
@@ -503,6 +567,12 @@ func includeContactInboxes(c *gin.Context) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func contactLabelsParam(c *gin.Context) []string {
|
||||
labels := c.QueryArray("labels[]")
|
||||
labels = append(labels, c.QueryArray("labels")...)
|
||||
return labels
|
||||
}
|
||||
|
||||
// parseIntOrDefault parses an integer query parameter with a default value.
|
||||
func parseIntOrDefault(c *gin.Context, key string, defaultVal int) int {
|
||||
val := c.Query(key)
|
||||
|
||||
@@ -49,6 +49,8 @@ func (s *ContactHandlerCRUDTestSuite) SetupSuite() {
|
||||
&model.AccountUser{},
|
||||
&model.Inbox{},
|
||||
&model.Contact{},
|
||||
&model.Tag{},
|
||||
&model.ContactLabel{},
|
||||
&model.Conversation{},
|
||||
&model.ContactInbox{},
|
||||
&model.InboxMember{},
|
||||
@@ -83,7 +85,10 @@ func (s *ContactHandlerCRUDTestSuite) SetupSuite() {
|
||||
s.router.POST("/api/v1/accounts/:id/contacts", s.handler.Create)
|
||||
s.router.PUT("/api/v1/accounts/:id/contacts/:contact_id", s.handler.Update)
|
||||
s.router.DELETE("/api/v1/accounts/:id/contacts/:contact_id", s.handler.Delete)
|
||||
s.router.DELETE("/api/v1/accounts/:id/contacts/:contact_id/avatar", s.handler.DeleteAvatar)
|
||||
s.router.GET("/api/v1/accounts/:id/contacts/:contact_id/conversations", s.handler.ListConversations)
|
||||
s.router.GET("/api/v1/accounts/:id/contacts/:contact_id/labels", s.handler.ListLabels)
|
||||
s.router.POST("/api/v1/accounts/:id/contacts/:contact_id/labels", s.handler.UpdateLabels)
|
||||
s.router.GET("/api/v1/accounts/:id/contacts/:contact_id/contact_inboxes", s.handler.ListContactInboxes)
|
||||
s.router.GET("/api/v1/accounts/:id/contacts/:contact_id/notes", s.handler.ListNotes)
|
||||
s.router.POST("/api/v1/accounts/:id/contacts/:contact_id/notes", s.handler.CreateNote)
|
||||
@@ -111,6 +116,8 @@ func (s *ContactHandlerCRUDTestSuite) SetupSuite() {
|
||||
// SetupTest re-creates core test data before each test so tests don't leak state.
|
||||
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 notes")
|
||||
s.db.Exec("DELETE FROM contact_inboxes")
|
||||
s.db.Exec("DELETE FROM conversations")
|
||||
@@ -532,6 +539,63 @@ func (s *ContactHandlerCRUDTestSuite) TestDelete_Success() {
|
||||
s.Error(err, "contact should be soft-deleted")
|
||||
}
|
||||
|
||||
func (s *ContactHandlerCRUDTestSuite) TestDeleteAvatar_Success() {
|
||||
s.Require().NoError(s.db.Model(s.contact).Update("avatar_url", "https://example.com/avatar.png").Error)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("DELETE",
|
||||
fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/avatar", s.account.ID, s.contact.ID), nil)
|
||||
s.router.ServeHTTP(w, req)
|
||||
|
||||
s.Equal(http.StatusOK, w.Code)
|
||||
|
||||
var resp map[string]interface{}
|
||||
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
payload := resp["payload"].(map[string]interface{})
|
||||
s.Equal("", payload["thumbnail"])
|
||||
|
||||
var found model.Contact
|
||||
s.Require().NoError(s.db.First(&found, s.contact.ID).Error)
|
||||
s.Equal("", found.AvatarURL)
|
||||
}
|
||||
|
||||
func (s *ContactHandlerCRUDTestSuite) TestLabels_UpdateListAndFilter() {
|
||||
bodyBytes, _ := json.Marshal(map[string]interface{}{"labels": []string{"vip", "trial"}})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST",
|
||||
fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/labels", s.account.ID, s.contact.ID),
|
||||
bytes.NewReader(bodyBytes))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
s.router.ServeHTTP(w, req)
|
||||
|
||||
s.Equal(http.StatusOK, w.Code)
|
||||
var resp map[string]interface{}
|
||||
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
s.ElementsMatch([]interface{}{"vip", "trial"}, resp["payload"])
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("GET",
|
||||
fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/labels", s.account.ID, s.contact.ID), nil)
|
||||
s.router.ServeHTTP(w, req)
|
||||
s.Equal(http.StatusOK, w.Code)
|
||||
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
s.ElementsMatch([]interface{}{"vip", "trial"}, resp["payload"])
|
||||
|
||||
other := &model.Contact{AccountID: s.account.ID, Name: "Other Contact"}
|
||||
s.Require().NoError(s.db.Create(other).Error)
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("GET",
|
||||
fmt.Sprintf("/api/v1/accounts/%d/contacts?labels%%5B%%5D=vip&page=1&page_size=25", s.account.ID), nil)
|
||||
s.router.ServeHTTP(w, req)
|
||||
s.Equal(http.StatusOK, w.Code)
|
||||
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
payload := resp["payload"].([]interface{})
|
||||
s.Len(payload, 1)
|
||||
s.Equal(float64(s.contact.ID), payload[0].(map[string]interface{})["id"])
|
||||
}
|
||||
|
||||
func (s *ContactHandlerCRUDTestSuite) TestDelete_InvalidAccountID() {
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("DELETE",
|
||||
|
||||
Reference in New Issue
Block a user