feat(crm): align contact company payloads
This commit is contained in:
@@ -173,6 +173,7 @@ GET /api/v1/accounts/:account_id/channels/facebook_channel/authorization
|
||||
GET /api/v1/accounts/:account_id/companies/
|
||||
GET /api/v1/accounts/:account_id/companies/:company_id
|
||||
GET /api/v1/accounts/:account_id/companies/:company_id/contacts
|
||||
GET /api/v1/accounts/:account_id/companies/:company_id/contacts/search
|
||||
GET /api/v1/accounts/:account_id/companies/:company_id/conversations
|
||||
GET /api/v1/accounts/:account_id/companies/:company_id/notes
|
||||
GET /api/v1/accounts/:account_id/companies/search
|
||||
@@ -434,6 +435,8 @@ GET /widget/widget/:website_token/uploads/:upload_uuid
|
||||
GET /ws
|
||||
PATCH /api/v1/accounts/:account_id/agent_bot_inboxes/:agent_bot_inbox_id/status
|
||||
PATCH /api/v1/accounts/:account_id/channels/facebook_channel/:fb_id
|
||||
PATCH /api/v1/accounts/:account_id/companies/:company_id
|
||||
PATCH /api/v1/accounts/:account_id/contacts/:contact_id
|
||||
PATCH /api/v1/accounts/:account_id/contacts/:contact_id/notes/:note_id
|
||||
PATCH /api/v1/accounts/:account_id/conversations/:conversation_id
|
||||
PATCH /api/v1/accounts/:account_id/conversations/:conversation_id/draft_messages/:draft_id
|
||||
@@ -531,6 +534,7 @@ POST /api/v1/accounts/:account_id/channels/facebook_channel/
|
||||
POST /api/v1/accounts/:account_id/channels/facebook_channel/oauth_callback
|
||||
POST /api/v1/accounts/:account_id/channels/facebook_channel/reauthorize
|
||||
POST /api/v1/accounts/:account_id/companies/
|
||||
POST /api/v1/accounts/:account_id/companies/:company_id/contacts
|
||||
POST /api/v1/accounts/:account_id/companies/:company_id/contacts/:contact_id
|
||||
POST /api/v1/accounts/:account_id/companies/:company_id/notes
|
||||
POST /api/v1/accounts/:account_id/contact_merge
|
||||
@@ -803,4 +807,4 @@ PUT /public/api/v1/csat_survey/:id
|
||||
PUT /public/api/v1/inboxes/:inbox_id/contacts/:contact_id
|
||||
PUT /public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/messages/:message_id
|
||||
PUT /widget/direct_uploads/:upload_uuid
|
||||
TOTAL: 805
|
||||
TOTAL: 809
|
||||
|
||||
@@ -43,7 +43,7 @@ func (h *CompanyHandler) List(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
response.OKWithMeta(c, companies, pg.Page, pg.PerPage, total)
|
||||
c.JSON(http.StatusOK, companyListResponse(c.Request.Context(), h.svc.DB(), companies, total, pg.Page))
|
||||
}
|
||||
|
||||
// Search searches companies by query.
|
||||
@@ -57,6 +57,10 @@ func (h *CompanyHandler) Search(c *gin.Context) {
|
||||
|
||||
pg := pagination.Parse(c)
|
||||
query := c.DefaultQuery("q", "")
|
||||
if query == "" {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "Specify search string with parameter q"})
|
||||
return
|
||||
}
|
||||
sort := c.DefaultQuery("sort", "")
|
||||
searchMode := search.SearchMode(c.DefaultQuery("search_mode", "ilike"))
|
||||
|
||||
@@ -67,7 +71,7 @@ func (h *CompanyHandler) Search(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
response.OKWithMeta(c, companies, pg.Page, pg.PerPage, total)
|
||||
c.JSON(http.StatusOK, companyListResponse(c.Request.Context(), h.svc.DB(), companies, total, pg.Page))
|
||||
}
|
||||
|
||||
// Get retrieves a single company by ID.
|
||||
@@ -91,7 +95,7 @@ func (h *CompanyHandler) Get(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, company)
|
||||
c.JSON(http.StatusOK, companyPayloadResponse(c.Request.Context(), h.svc.DB(), company))
|
||||
}
|
||||
|
||||
// Create creates a new company.
|
||||
@@ -115,7 +119,7 @@ func (h *CompanyHandler) Create(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
response.Created(c, company)
|
||||
c.JSON(http.StatusOK, companyPayloadResponse(c.Request.Context(), h.svc.DB(), company))
|
||||
}
|
||||
|
||||
// Update updates an existing company.
|
||||
@@ -145,7 +149,7 @@ func (h *CompanyHandler) Update(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, company)
|
||||
c.JSON(http.StatusOK, companyPayloadResponse(c.Request.Context(), h.svc.DB(), company))
|
||||
}
|
||||
|
||||
// Delete deletes a company.
|
||||
@@ -168,7 +172,7 @@ func (h *CompanyHandler) Delete(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
response.NoContent(c)
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
// ListContacts retrieves contacts associated with a company.
|
||||
@@ -194,7 +198,7 @@ func (h *CompanyHandler) ListContacts(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
response.OKWithMeta(c, contacts, pg.Page, pg.PerPage, total)
|
||||
c.JSON(http.StatusOK, companyContactsResponse(c.Request.Context(), h.svc.DB(), uint(companyID), contacts, total, pg.Page))
|
||||
}
|
||||
|
||||
// ListConversations retrieves conversations for contacts of a company.
|
||||
@@ -220,7 +224,35 @@ func (h *CompanyHandler) ListConversations(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
response.OKWithMeta(c, conversations, pg.Page, pg.PerPage, total)
|
||||
_ = total
|
||||
c.JSON(http.StatusOK, gin.H{"payload": conversations})
|
||||
}
|
||||
|
||||
func (h *CompanyHandler) SearchContacts(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
|
||||
}
|
||||
|
||||
query := c.Query("q")
|
||||
if query == "" {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "Specify search string with parameter q"})
|
||||
return
|
||||
}
|
||||
pg := pagination.Parse(c)
|
||||
contacts, total, svcErr := h.svc.SearchContacts(c.Request.Context(), uint(companyID), accountID, query, pg.Offset, pg.PerPage)
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, companyContactsResponse(c.Request.Context(), h.svc.DB(), uint(companyID), contacts, total, pg.Page))
|
||||
}
|
||||
|
||||
// ListNotes retrieves notes for a company.
|
||||
@@ -246,7 +278,8 @@ func (h *CompanyHandler) ListNotes(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
response.OKWithMeta(c, notes, pg.Page, pg.PerPage, total)
|
||||
_ = total
|
||||
c.JSON(http.StatusOK, companyNotesResponse(c.Request.Context(), h.svc.DB(), notes))
|
||||
}
|
||||
|
||||
// CreateNote creates a note for a company.
|
||||
@@ -282,7 +315,7 @@ func (h *CompanyHandler) CreateNote(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
response.Created(c, note)
|
||||
c.JSON(http.StatusOK, gin.H{"payload": note})
|
||||
}
|
||||
|
||||
// DeleteNote deletes a note from a company.
|
||||
@@ -311,7 +344,7 @@ func (h *CompanyHandler) DeleteNote(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, gin.H{"message": "note deleted"})
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
// AddContact adds a contact to a company.
|
||||
@@ -329,7 +362,7 @@ func (h *CompanyHandler) AddContact(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
contactID, err := strconv.ParseUint(c.Param("contact_id"), 10, 32)
|
||||
contactID, err := parseCompanyContactID(c)
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid contact id")
|
||||
return
|
||||
@@ -340,7 +373,12 @@ func (h *CompanyHandler) AddContact(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, gin.H{"message": "contact added"})
|
||||
contact, svcErr := h.svc.GetContact(c.Request.Context(), uint(companyID), accountID, contactID)
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"payload": serializeCompanyContact(c.Request.Context(), h.svc.DB(), contact, uint(companyID))})
|
||||
}
|
||||
|
||||
// RemoveContact removes a contact from a company.
|
||||
@@ -369,5 +407,21 @@ func (h *CompanyHandler) RemoveContact(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, gin.H{"message": "contact removed"})
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
func parseCompanyContactID(c *gin.Context) (uint, error) {
|
||||
if c.Param("contact_id") != "" {
|
||||
return parseUintParam(c, "contact_id")
|
||||
}
|
||||
var req struct {
|
||||
ContactID uint `json:"contact_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if req.ContactID == 0 {
|
||||
return 0, strconv.ErrSyntax
|
||||
}
|
||||
return req.ContactID, nil
|
||||
}
|
||||
|
||||
@@ -101,8 +101,11 @@ func (s *CompanyHandlerTestSuite) SetupSuite() {
|
||||
companies.GET("/search", s.handler.Search)
|
||||
companies.GET("/:company_id", s.handler.Get)
|
||||
companies.PUT("/:company_id", s.handler.Update)
|
||||
companies.PATCH("/:company_id", s.handler.Update)
|
||||
companies.DELETE("/:company_id", s.handler.Delete)
|
||||
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)
|
||||
companies.POST("/:company_id/contacts/:contact_id", s.handler.AddContact)
|
||||
companies.DELETE("/:company_id/contacts/:contact_id", s.handler.RemoveContact)
|
||||
companies.GET("/:company_id/conversations", s.handler.ListConversations)
|
||||
@@ -147,8 +150,9 @@ func (s *CompanyHandlerTestSuite) TestList_Empty() {
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
assert.True(s.T(), resp["success"].(bool))
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Contains(s.T(), resp, "payload")
|
||||
assert.Equal(s.T(), float64(0), resp["meta"].(map[string]interface{})["total_count"])
|
||||
}
|
||||
|
||||
func (s *CompanyHandlerTestSuite) TestList_WithCompanies() {
|
||||
@@ -163,29 +167,30 @@ func (s *CompanyHandlerTestSuite) TestList_WithCompanies() {
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
assert.True(s.T(), resp["success"].(bool))
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
|
||||
data := resp["data"].([]interface{})
|
||||
assert.Len(s.T(), data, 2)
|
||||
payload := resp["payload"].([]interface{})
|
||||
assert.Len(s.T(), payload, 2)
|
||||
assert.Equal(s.T(), float64(2), resp["meta"].(map[string]interface{})["total_count"])
|
||||
}
|
||||
|
||||
// ========== Create ==========
|
||||
|
||||
func (s *CompanyHandlerTestSuite) TestCreate_Success() {
|
||||
body := map[string]interface{}{
|
||||
"name": "NewCorp",
|
||||
"description": "A new company",
|
||||
"domain": "newcorp.com",
|
||||
"company": map[string]interface{}{
|
||||
"name": "NewCorp",
|
||||
"description": "A new company",
|
||||
"domain": "newcorp.com",
|
||||
},
|
||||
}
|
||||
w := s.makeRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/companies/", s.accountID), body)
|
||||
assert.Equal(s.T(), http.StatusCreated, w.Code)
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
assert.True(s.T(), resp["success"].(bool))
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
|
||||
companyData := resp["data"].(map[string]interface{})
|
||||
companyData := resp["payload"].(map[string]interface{})
|
||||
assert.Equal(s.T(), "NewCorp", companyData["name"])
|
||||
}
|
||||
|
||||
@@ -204,9 +209,9 @@ func (s *CompanyHandlerTestSuite) TestSearch_NoResult() {
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
data := resp["data"].([]interface{})
|
||||
assert.Len(s.T(), data, 0)
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
payload := resp["payload"].([]interface{})
|
||||
assert.Len(s.T(), payload, 0)
|
||||
}
|
||||
|
||||
// ========== Get ==========
|
||||
@@ -220,10 +225,9 @@ func (s *CompanyHandlerTestSuite) TestGet_Success() {
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
assert.True(s.T(), resp["success"].(bool))
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
|
||||
companyData := resp["data"].(map[string]interface{})
|
||||
companyData := resp["payload"].(map[string]interface{})
|
||||
assert.Equal(s.T(), "GetCorp", companyData["name"])
|
||||
}
|
||||
|
||||
@@ -247,17 +251,18 @@ func (s *CompanyHandlerTestSuite) TestUpdate_Success() {
|
||||
s.Require().NoError(companyRepo.Create(context.Background(), company))
|
||||
|
||||
body := map[string]interface{}{
|
||||
"name": "UpdateCorpUpdated",
|
||||
"domain": "updated.com",
|
||||
"company": map[string]interface{}{
|
||||
"name": "UpdateCorpUpdated",
|
||||
"domain": "updated.com",
|
||||
},
|
||||
}
|
||||
w := s.makeRequest("PUT", fmt.Sprintf("/api/v1/accounts/%d/companies/%d", s.accountID, company.ID), body)
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
assert.True(s.T(), resp["success"].(bool))
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
|
||||
companyData := resp["data"].(map[string]interface{})
|
||||
companyData := resp["payload"].(map[string]interface{})
|
||||
assert.Equal(s.T(), "UpdateCorpUpdated", companyData["name"])
|
||||
}
|
||||
|
||||
@@ -277,7 +282,8 @@ func (s *CompanyHandlerTestSuite) TestDelete_Success() {
|
||||
s.Require().NoError(companyRepo.Create(context.Background(), company))
|
||||
|
||||
w := s.makeRequest("DELETE", fmt.Sprintf("/api/v1/accounts/%d/companies/%d", s.accountID, company.ID), nil)
|
||||
assert.Equal(s.T(), http.StatusNoContent, w.Code)
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
assert.Empty(s.T(), w.Body.String())
|
||||
}
|
||||
|
||||
func (s *CompanyHandlerTestSuite) TestDelete_InvalidID() {
|
||||
@@ -294,19 +300,19 @@ func (s *CompanyHandlerTestSuite) TestListContacts_Success() {
|
||||
s.Require().NoError(companyRepo.Create(context.Background(), company))
|
||||
|
||||
// Create contacts and link them
|
||||
contact1 := &model.Contact{AccountID: s.accountID, Name: "Contact1"}
|
||||
contact2 := &model.Contact{AccountID: s.accountID, Name: "Contact2"}
|
||||
contact1 := &model.Contact{AccountID: s.accountID, Name: "Contact1", CompanyID: &company.ID}
|
||||
contact2 := &model.Contact{AccountID: s.accountID, Name: "Contact2", CompanyID: &company.ID}
|
||||
s.Require().NoError(s.db.Create(contact1).Error)
|
||||
s.Require().NoError(s.db.Create(contact2).Error)
|
||||
s.Require().NoError(s.db.Exec("INSERT INTO company_contacts (company_id, contact_id) VALUES (?, ?)", company.ID, contact1.ID).Error)
|
||||
s.Require().NoError(s.db.Exec("INSERT INTO company_contacts (company_id, contact_id) VALUES (?, ?)", company.ID, contact2.ID).Error)
|
||||
|
||||
w := s.makeRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/companies/%d/contacts", s.accountID, company.ID), nil)
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
assert.True(s.T(), resp["success"].(bool))
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
payload := resp["payload"].([]interface{})
|
||||
assert.Len(s.T(), payload, 2)
|
||||
assert.Equal(s.T(), float64(2), resp["meta"].(map[string]interface{})["total_count"])
|
||||
}
|
||||
|
||||
func (s *CompanyHandlerTestSuite) TestListContacts_InvalidID() {
|
||||
@@ -343,11 +349,10 @@ func (s *CompanyHandlerTestSuite) TestListNotes_Success() {
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
assert.True(s.T(), resp["success"].(bool))
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
|
||||
data := resp["data"].([]interface{})
|
||||
assert.Len(s.T(), data, 2)
|
||||
payload := resp["payload"].([]interface{})
|
||||
assert.Len(s.T(), payload, 2)
|
||||
}
|
||||
|
||||
// ========== CreateNote ==========
|
||||
@@ -361,13 +366,12 @@ func (s *CompanyHandlerTestSuite) TestCreateNote_Success() {
|
||||
"content": "This is a new note",
|
||||
}
|
||||
w := s.makeRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/companies/%d/notes", s.accountID, company.ID), body)
|
||||
assert.Equal(s.T(), http.StatusCreated, w.Code)
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
assert.True(s.T(), resp["success"].(bool))
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
|
||||
noteData := resp["data"].(map[string]interface{})
|
||||
noteData := resp["payload"].(map[string]interface{})
|
||||
assert.Equal(s.T(), "This is a new note", noteData["content"])
|
||||
}
|
||||
|
||||
@@ -405,10 +409,7 @@ func (s *CompanyHandlerTestSuite) TestDeleteNote_Success() {
|
||||
|
||||
w := s.makeRequest("DELETE", fmt.Sprintf("/api/v1/accounts/%d/companies/%d/notes/%d", s.accountID, company.ID, note.ID), nil)
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
assert.True(s.T(), resp["success"].(bool))
|
||||
assert.Empty(s.T(), w.Body.String())
|
||||
}
|
||||
|
||||
func (s *CompanyHandlerTestSuite) TestDeleteNote_InvalidCompanyID() {
|
||||
@@ -441,8 +442,15 @@ func (s *CompanyHandlerTestSuite) TestAddContact_Success() {
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
assert.True(s.T(), resp["success"].(bool))
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
payload := resp["payload"].(map[string]interface{})
|
||||
assert.Equal(s.T(), "Test Contact", payload["name"])
|
||||
assert.True(s.T(), payload["linked_to_current_company"].(bool))
|
||||
|
||||
var updated model.Contact
|
||||
s.Require().NoError(s.db.First(&updated, contact.ID).Error)
|
||||
s.Require().NotNil(updated.CompanyID)
|
||||
assert.Equal(s.T(), company.ID, *updated.CompanyID)
|
||||
}
|
||||
|
||||
func (s *CompanyHandlerTestSuite) TestAddContact_InvalidCompanyID() {
|
||||
@@ -471,18 +479,16 @@ func (s *CompanyHandlerTestSuite) TestRemoveContact_Success() {
|
||||
company := &model.Company{AccountID: s.accountID, Name: "RemoveContactCorp"}
|
||||
s.Require().NoError(companyRepo.Create(context.Background(), company))
|
||||
|
||||
contact := &model.Contact{AccountID: s.accountID, Name: "Remove Contact", Email: "removecontact@example.com"}
|
||||
contact := &model.Contact{AccountID: s.accountID, Name: "Remove Contact", Email: "removecontact@example.com", CompanyID: &company.ID}
|
||||
s.Require().NoError(s.db.Create(contact).Error)
|
||||
|
||||
// Add association first
|
||||
s.Require().NoError(s.db.Model(&company).Association("Contacts").Append(contact))
|
||||
|
||||
w := s.makeRequest("DELETE", fmt.Sprintf("/api/v1/accounts/%d/companies/%d/contacts/%d", s.accountID, company.ID, contact.ID), nil)
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
assert.Empty(s.T(), w.Body.String())
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
assert.True(s.T(), resp["success"].(bool))
|
||||
var updated model.Contact
|
||||
s.Require().NoError(s.db.First(&updated, contact.ID).Error)
|
||||
assert.Nil(s.T(), updated.CompanyID)
|
||||
}
|
||||
|
||||
func (s *CompanyHandlerTestSuite) TestRemoveContact_InvalidCompanyID() {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/repository"
|
||||
"github.com/gochat/gochat/internal/search"
|
||||
"github.com/gochat/gochat/internal/service"
|
||||
@@ -68,10 +69,7 @@ func (h *ContactHandler) List(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"contacts": contacts,
|
||||
"meta": gin.H{"count": total, "page": page, "page_size": perPage},
|
||||
})
|
||||
c.JSON(http.StatusOK, contactListResponse(c.Request.Context(), h.svc.DB(), contacts, total, page, includeContactInboxes(c), nil))
|
||||
}
|
||||
|
||||
// @Summary Search contacts
|
||||
@@ -102,6 +100,10 @@ func (h *ContactHandler) Search(c *gin.Context) {
|
||||
}
|
||||
|
||||
query := c.Query("q")
|
||||
if query == "" {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "Specify search string with parameter q"})
|
||||
return
|
||||
}
|
||||
page := getPage(c)
|
||||
perPage := getPageSize(c)
|
||||
offset := (page - 1) * perPage
|
||||
@@ -114,10 +116,8 @@ func (h *ContactHandler) Search(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"contacts": contacts,
|
||||
"meta": gin.H{"count": total, "page": page, "page_size": perPage},
|
||||
})
|
||||
hasMore := int64(len(contacts)) < total
|
||||
c.JSON(http.StatusOK, contactListResponse(c.Request.Context(), h.svc.DB(), contacts, int64(len(contacts)), page, includeContactInboxes(c), &hasMore))
|
||||
}
|
||||
|
||||
// @Summary Get a single contact
|
||||
@@ -155,7 +155,7 @@ func (h *ContactHandler) Get(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, contact)
|
||||
c.JSON(http.StatusOK, contactPayloadResponse(c.Request.Context(), h.svc.DB(), contact, includeContactInboxes(c)))
|
||||
}
|
||||
|
||||
// @Summary Create a new contact
|
||||
@@ -194,7 +194,14 @@ func (h *ContactHandler) Create(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, contact)
|
||||
var contactInbox *model.ContactInbox
|
||||
if h.svc.DB() != nil {
|
||||
var ci model.ContactInbox
|
||||
if err := h.svc.DB().WithContext(c.Request.Context()).Preload("Inbox").Where("contact_id = ?", contact.ID).Order("id DESC").First(&ci).Error; err == nil {
|
||||
contactInbox = &ci
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, contactCreateResponse(c.Request.Context(), h.svc.DB(), contact, contactInbox))
|
||||
}
|
||||
|
||||
// @Summary Update a contact
|
||||
@@ -239,7 +246,7 @@ func (h *ContactHandler) Update(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, contact)
|
||||
c.JSON(http.StatusOK, contactPayloadResponse(c.Request.Context(), h.svc.DB(), contact, includeContactInboxes(c)))
|
||||
}
|
||||
|
||||
// @Summary Delete a contact
|
||||
@@ -276,7 +283,7 @@ func (h *ContactHandler) Delete(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "contact deleted"})
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
// ListContactInboxes retrieves all contact_inboxes for a contact.
|
||||
@@ -295,10 +302,11 @@ func (h *ContactHandler) ListContactInboxes(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"contact_inboxes": contactInboxes,
|
||||
"meta": gin.H{"count": len(contactInboxes)},
|
||||
})
|
||||
payload := make([]any, 0, len(contactInboxes))
|
||||
for i := range contactInboxes {
|
||||
payload = append(payload, serializeContactInbox(&contactInboxes[i]))
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"payload": payload, "meta": gin.H{"count": len(payload)}})
|
||||
}
|
||||
|
||||
// ListConversations retrieves recent conversations for a contact.
|
||||
@@ -368,9 +376,7 @@ func (h *ContactHandler) ListNotes(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"notes": notes,
|
||||
})
|
||||
c.JSON(http.StatusOK, gin.H{"payload": notes})
|
||||
}
|
||||
|
||||
// CreateNote creates a note for a contact.
|
||||
@@ -490,6 +496,13 @@ func (h *ContactHandler) DestroyNote(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func includeContactInboxes(c *gin.Context) bool {
|
||||
if raw := c.Query("include_contact_inboxes"); raw != "" {
|
||||
return raw == "true"
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// parseIntOrDefault parses an integer query parameter with a default value.
|
||||
func parseIntOrDefault(c *gin.Context, key string, defaultVal int) int {
|
||||
val := c.Query(key)
|
||||
@@ -636,7 +649,10 @@ func (h *ContactHandler) Import(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
file, _, fileErr := c.Request.FormFile("file")
|
||||
file, _, fileErr := c.Request.FormFile("import_file")
|
||||
if fileErr != nil {
|
||||
file, _, fileErr = c.Request.FormFile("file")
|
||||
}
|
||||
if fileErr != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "csv file required"})
|
||||
return
|
||||
@@ -674,7 +690,11 @@ func (h *ContactHandler) ContactableInboxes(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"payload": inboxes})
|
||||
payload := make([]any, 0, len(inboxes))
|
||||
for _, item := range inboxes {
|
||||
payload = append(payload, map[string]any{"inbox": serializeInboxSlim(&item.Inbox), "source_id": item.SourceID})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"payload": payload})
|
||||
}
|
||||
|
||||
// DeleteCustomAttributes removes all custom attributes from a contact.
|
||||
@@ -698,7 +718,12 @@ func (h *ContactHandler) DeleteCustomAttributes(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "custom attributes deleted"})
|
||||
contact, svcErr := h.svc.GetByAccountAndID(c.Request.Context(), accountID, contactID)
|
||||
if svcErr != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to load contact"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, contactPayloadResponse(c.Request.Context(), h.svc.DB(), contact, true))
|
||||
}
|
||||
|
||||
// Merge two contacts into one. The base contact survives, mergee is deleted.
|
||||
@@ -749,7 +774,7 @@ func (h *ContactHandler) Filter(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
response.OKWithMeta(c, contacts, p.Page, p.PerPage, total)
|
||||
c.JSON(http.StatusOK, contactListResponse(c.Request.Context(), h.svc.DB(), contacts, total, p.Page, includeContactInboxes(c), nil))
|
||||
}
|
||||
|
||||
// DestroyCustomAttributes removes all custom attributes from a contact.
|
||||
@@ -769,10 +794,18 @@ func (h *ContactHandler) DestroyCustomAttributes(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if svcErr := h.svc.DeleteCustomAttributes(c.Request.Context(), accountID, contactID); svcErr != nil {
|
||||
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
|
||||
}
|
||||
contact, svcErr := h.svc.DestroyCustomAttributes(c.Request.Context(), accountID, contactID, req.CustomAttributes)
|
||||
if svcErr != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to destroy custom attributes"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "custom attributes destroyed"})
|
||||
c.JSON(http.StatusOK, contactPayloadResponse(c.Request.Context(), h.svc.DB(), contact, true))
|
||||
}
|
||||
|
||||
@@ -183,12 +183,13 @@ func (s *ContactHandlerCRUDTestSuite) TestList_Success() {
|
||||
var resp map[string]interface{}
|
||||
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
|
||||
s.Contains(resp, "contacts")
|
||||
s.Contains(resp, "payload")
|
||||
|
||||
meta, ok := resp["meta"]
|
||||
s.True(ok, "response should contain 'meta' key")
|
||||
metaMap := meta.(map[string]interface{})
|
||||
s.Equal(float64(1), metaMap["count"])
|
||||
s.Equal(float64(1), metaMap["current_page"])
|
||||
}
|
||||
|
||||
func (s *ContactHandlerCRUDTestSuite) TestList_InvalidAccountID() {
|
||||
@@ -227,6 +228,8 @@ func (s *ContactHandlerCRUDTestSuite) TestList_Pagination() {
|
||||
s.True(ok)
|
||||
metaMap := meta.(map[string]interface{})
|
||||
s.Equal(float64(4), metaMap["count"]) // 1 original + 3 new
|
||||
payload := resp["payload"].([]interface{})
|
||||
s.Len(payload, 2)
|
||||
}
|
||||
|
||||
// ===========================
|
||||
@@ -234,7 +237,6 @@ func (s *ContactHandlerCRUDTestSuite) TestList_Pagination() {
|
||||
// ===========================
|
||||
|
||||
func (s *ContactHandlerCRUDTestSuite) TestSearch_Success() {
|
||||
skipIfSQLiteForHandler(s.T()) // Search uses ILIKE which SQLite doesn't support
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET",
|
||||
fmt.Sprintf("/api/v1/accounts/%d/contacts/search?q=Jane&page=1&page_size=25", s.account.ID), nil)
|
||||
@@ -244,7 +246,7 @@ func (s *ContactHandlerCRUDTestSuite) TestSearch_Success() {
|
||||
|
||||
var resp map[string]interface{}
|
||||
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
s.Contains(resp, "contacts")
|
||||
s.Contains(resp, "payload")
|
||||
s.Contains(resp, "meta")
|
||||
}
|
||||
|
||||
@@ -254,12 +256,11 @@ func (s *ContactHandlerCRUDTestSuite) TestSearch_EmptyQuery() {
|
||||
fmt.Sprintf("/api/v1/accounts/%d/contacts/search?q=&page=1&page_size=25", s.account.ID), nil)
|
||||
s.router.ServeHTTP(w, req)
|
||||
|
||||
// Empty query falls back to ListByAccount
|
||||
s.Equal(http.StatusOK, w.Code)
|
||||
s.Equal(http.StatusUnprocessableEntity, w.Code)
|
||||
|
||||
var resp map[string]interface{}
|
||||
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
s.Contains(resp, "contacts")
|
||||
s.Contains(resp, "error")
|
||||
}
|
||||
|
||||
func (s *ContactHandlerCRUDTestSuite) TestSearch_InvalidAccountID() {
|
||||
@@ -286,10 +287,11 @@ func (s *ContactHandlerCRUDTestSuite) TestGet_Success() {
|
||||
|
||||
s.Equal(http.StatusOK, w.Code)
|
||||
|
||||
var contact model.Contact
|
||||
s.NoError(json.Unmarshal(w.Body.Bytes(), &contact))
|
||||
s.Equal(s.contact.ID, contact.ID)
|
||||
s.Equal("Jane Doe", contact.Name)
|
||||
var resp map[string]interface{}
|
||||
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
payload := resp["payload"].(map[string]interface{})
|
||||
s.Equal(float64(s.contact.ID), payload["id"])
|
||||
s.Equal("Jane Doe", payload["name"])
|
||||
}
|
||||
|
||||
func (s *ContactHandlerCRUDTestSuite) TestGet_InvalidAccountID() {
|
||||
@@ -351,13 +353,16 @@ func (s *ContactHandlerCRUDTestSuite) TestCreate_Success() {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
s.router.ServeHTTP(w, req)
|
||||
|
||||
s.Equal(http.StatusCreated, w.Code)
|
||||
s.Equal(http.StatusOK, w.Code)
|
||||
|
||||
var contact model.Contact
|
||||
s.NoError(json.Unmarshal(w.Body.Bytes(), &contact))
|
||||
s.Equal("New Contact", contact.Name)
|
||||
s.Equal("new@example.com", contact.Email)
|
||||
s.NotZero(contact.ID)
|
||||
var resp map[string]interface{}
|
||||
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
payload := resp["payload"].(map[string]interface{})
|
||||
contact := payload["contact"].(map[string]interface{})
|
||||
s.Equal("New Contact", contact["name"])
|
||||
s.Equal("new@example.com", contact["email"])
|
||||
s.NotZero(contact["id"])
|
||||
s.Contains(payload, "contact_inbox")
|
||||
}
|
||||
|
||||
func (s *ContactHandlerCRUDTestSuite) TestCreate_InvalidAccountID() {
|
||||
@@ -413,11 +418,15 @@ func (s *ContactHandlerCRUDTestSuite) TestCreate_WithCustomAttributes() {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
s.router.ServeHTTP(w, req)
|
||||
|
||||
s.Equal(http.StatusCreated, w.Code)
|
||||
s.Equal(http.StatusOK, w.Code)
|
||||
|
||||
var contact model.Contact
|
||||
s.NoError(json.Unmarshal(w.Body.Bytes(), &contact))
|
||||
s.Equal("Custom Contact", contact.Name)
|
||||
var resp map[string]interface{}
|
||||
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
payload := resp["payload"].(map[string]interface{})
|
||||
contact := payload["contact"].(map[string]interface{})
|
||||
s.Equal("Custom Contact", contact["name"])
|
||||
customAttrs := contact["custom_attributes"].(map[string]interface{})
|
||||
s.Equal("gold", customAttrs["tier"])
|
||||
}
|
||||
|
||||
// ===========================
|
||||
@@ -441,10 +450,11 @@ func (s *ContactHandlerCRUDTestSuite) TestUpdate_Success() {
|
||||
|
||||
s.Equal(http.StatusOK, w.Code)
|
||||
|
||||
var contact model.Contact
|
||||
s.NoError(json.Unmarshal(w.Body.Bytes(), &contact))
|
||||
s.Equal("Jane Updated", contact.Name)
|
||||
s.Equal("jane.updated@example.com", contact.Email)
|
||||
var resp map[string]interface{}
|
||||
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
payload := resp["payload"].(map[string]interface{})
|
||||
s.Equal("Jane Updated", payload["name"])
|
||||
s.Equal("jane.updated@example.com", payload["email"])
|
||||
}
|
||||
|
||||
func (s *ContactHandlerCRUDTestSuite) TestUpdate_InvalidAccountID() {
|
||||
@@ -514,9 +524,7 @@ func (s *ContactHandlerCRUDTestSuite) TestDelete_Success() {
|
||||
|
||||
s.Equal(http.StatusOK, w.Code)
|
||||
|
||||
var resp map[string]interface{}
|
||||
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
s.Contains(resp, "message")
|
||||
s.Empty(w.Body.String())
|
||||
|
||||
// Verify soft-delete
|
||||
var found model.Contact
|
||||
@@ -581,7 +589,9 @@ func (s *ContactHandlerCRUDTestSuite) TestListContactInboxes_Success() {
|
||||
|
||||
var resp map[string]interface{}
|
||||
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
s.Contains(resp, "contact_inboxes")
|
||||
s.Contains(resp, "payload")
|
||||
payload := resp["payload"].([]interface{})
|
||||
s.Len(payload, 1)
|
||||
|
||||
meta := resp["meta"].(map[string]interface{})
|
||||
s.Equal(float64(1), meta["count"])
|
||||
@@ -618,7 +628,7 @@ func (s *ContactHandlerCRUDTestSuite) TestListContactInboxes_EmptyResult() {
|
||||
var resp map[string]interface{}
|
||||
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
|
||||
cis := resp["contact_inboxes"]
|
||||
cis := resp["payload"]
|
||||
s.NotNil(cis)
|
||||
|
||||
meta := resp["meta"].(map[string]interface{})
|
||||
@@ -683,7 +693,7 @@ func (s *ContactHandlerCRUDTestSuite) TestListNotes_Success() {
|
||||
|
||||
var resp map[string]interface{}
|
||||
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
s.Contains(resp, "notes")
|
||||
s.Contains(resp, "payload")
|
||||
}
|
||||
|
||||
func (s *ContactHandlerCRUDTestSuite) TestListNotes_InvalidAccountID() {
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type chatwootContactMeta struct {
|
||||
Count int64 `json:"count"`
|
||||
CurrentPage int `json:"current_page"`
|
||||
HasMore *bool `json:"has_more,omitempty"`
|
||||
}
|
||||
|
||||
type chatwootCompanyMeta struct {
|
||||
TotalCount int64 `json:"total_count"`
|
||||
Page int `json:"page"`
|
||||
}
|
||||
|
||||
func contactListResponse(ctx context.Context, db *gorm.DB, contacts []model.Contact, total int64, page int, includeInboxes bool, hasMore *bool) map[string]any {
|
||||
payload := make([]any, 0, len(contacts))
|
||||
for i := range contacts {
|
||||
payload = append(payload, serializeCRMContact(ctx, db, &contacts[i], includeInboxes))
|
||||
}
|
||||
return map[string]any{
|
||||
"meta": chatwootContactMeta{Count: total, CurrentPage: page, HasMore: hasMore},
|
||||
"payload": payload,
|
||||
}
|
||||
}
|
||||
|
||||
func contactPayloadResponse(ctx context.Context, db *gorm.DB, contact *model.Contact, includeInboxes bool) map[string]any {
|
||||
payload := serializeCRMContact(ctx, db, contact, includeInboxes)
|
||||
return map[string]any{"payload": payload}
|
||||
}
|
||||
|
||||
func contactCreateResponse(ctx context.Context, db *gorm.DB, contact *model.Contact, contactInbox *model.ContactInbox) map[string]any {
|
||||
payload := map[string]any{
|
||||
"contact": serializeCRMContact(ctx, db, contact, true),
|
||||
"contact_inbox": serializeContactInboxShell(contactInbox),
|
||||
}
|
||||
return map[string]any{"payload": payload}
|
||||
}
|
||||
|
||||
func serializeCRMContact(ctx context.Context, db *gorm.DB, contact *model.Contact, includeInboxes bool) map[string]any {
|
||||
payload := map[string]any{
|
||||
"additional_attributes": jsonObject(contact.AdditionalAttributes),
|
||||
"availability_status": "offline",
|
||||
"email": contact.Email,
|
||||
"id": contact.ID,
|
||||
"name": contact.Name,
|
||||
"phone_number": contact.PhoneNumber,
|
||||
"blocked": contact.Blocked,
|
||||
"identifier": contact.Identifier,
|
||||
"thumbnail": contact.AvatarURL,
|
||||
"custom_attributes": jsonObject(contact.CustomAttributes),
|
||||
}
|
||||
if contact.LastActivityAt != nil {
|
||||
payload["last_activity_at"] = *contact.LastActivityAt
|
||||
}
|
||||
if !contact.CreatedAt.IsZero() {
|
||||
payload["created_at"] = contact.CreatedAt.Unix()
|
||||
}
|
||||
if includeInboxes {
|
||||
payload["contact_inboxes"] = []any{}
|
||||
if db != nil {
|
||||
var contactInboxes []model.ContactInbox
|
||||
if err := db.WithContext(ctx).Preload("Inbox").Where("contact_id = ?", contact.ID).Order("id ASC").Find(&contactInboxes).Error; err == nil {
|
||||
items := make([]any, 0, len(contactInboxes))
|
||||
for i := range contactInboxes {
|
||||
items = append(items, serializeContactInbox(&contactInboxes[i]))
|
||||
}
|
||||
payload["contact_inboxes"] = items
|
||||
}
|
||||
}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func serializeCompanyContact(ctx context.Context, db *gorm.DB, contact *model.Contact, currentCompanyID uint) map[string]any {
|
||||
payload := serializeCRMContact(ctx, db, contact, false)
|
||||
payload["company_id"] = contact.CompanyID
|
||||
payload["linked_to_current_company"] = contact.CompanyID != nil && *contact.CompanyID == currentCompanyID
|
||||
payload["company"] = nil
|
||||
if db != nil && contact.CompanyID != nil {
|
||||
var company model.Company
|
||||
if err := db.WithContext(ctx).First(&company, *contact.CompanyID).Error; err == nil {
|
||||
payload["company"] = serializeCompany(ctx, db, &company)
|
||||
}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func serializeContactInbox(contactInbox *model.ContactInbox) map[string]any {
|
||||
return map[string]any{
|
||||
"source_id": contactInbox.SourceID,
|
||||
"inbox": serializeInboxSlim(&contactInbox.Inbox),
|
||||
}
|
||||
}
|
||||
|
||||
func serializeContactInboxShell(contactInbox *model.ContactInbox) map[string]any {
|
||||
if contactInbox == nil {
|
||||
return map[string]any{"inbox": nil, "source_id": nil}
|
||||
}
|
||||
return map[string]any{"inbox": contactInbox.Inbox, "source_id": contactInbox.SourceID}
|
||||
}
|
||||
|
||||
func serializeInboxSlim(inbox *model.Inbox) map[string]any {
|
||||
if inbox == nil || inbox.ID == 0 {
|
||||
return map[string]any{}
|
||||
}
|
||||
return map[string]any{
|
||||
"id": inbox.ID,
|
||||
"name": inbox.Name,
|
||||
"channel_type": inbox.ChannelType,
|
||||
"account_id": inbox.AccountID,
|
||||
}
|
||||
}
|
||||
|
||||
func companyListResponse(ctx context.Context, db *gorm.DB, companies []model.Company, total int64, page int) map[string]any {
|
||||
payload := make([]any, 0, len(companies))
|
||||
for i := range companies {
|
||||
payload = append(payload, serializeCompany(ctx, db, &companies[i]))
|
||||
}
|
||||
return map[string]any{
|
||||
"meta": chatwootCompanyMeta{TotalCount: total, Page: page},
|
||||
"payload": payload,
|
||||
}
|
||||
}
|
||||
|
||||
func companyPayloadResponse(ctx context.Context, db *gorm.DB, company *model.Company) map[string]any {
|
||||
payload := serializeCompany(ctx, db, company)
|
||||
return map[string]any{"payload": payload}
|
||||
}
|
||||
|
||||
func serializeCompany(ctx context.Context, db *gorm.DB, company *model.Company) map[string]any {
|
||||
return map[string]any{
|
||||
"id": company.ID,
|
||||
"name": company.Name,
|
||||
"contacts_count": companyContactsCount(ctx, db, company),
|
||||
"domain": company.Domain,
|
||||
"description": company.Description,
|
||||
"custom_attributes": jsonObject(company.CustomAttributes),
|
||||
"avatar_url": company.FaviconURL,
|
||||
"last_activity_at": unixTimePtr(company.LastActivityAt),
|
||||
"created_at": company.CreatedAt.Unix(),
|
||||
"updated_at": company.UpdatedAt.Unix(),
|
||||
}
|
||||
}
|
||||
|
||||
func companyContactsResponse(ctx context.Context, db *gorm.DB, companyID uint, contacts []model.Contact, total int64, page int) map[string]any {
|
||||
payload := make([]any, 0, len(contacts))
|
||||
for i := range contacts {
|
||||
payload = append(payload, serializeCompanyContact(ctx, db, &contacts[i], companyID))
|
||||
}
|
||||
return map[string]any{
|
||||
"meta": chatwootCompanyMeta{TotalCount: total, Page: page},
|
||||
"payload": payload,
|
||||
}
|
||||
}
|
||||
|
||||
func companyNotesResponse(ctx context.Context, db *gorm.DB, notes []model.CompanyNote) map[string]any {
|
||||
payload := make([]any, 0, len(notes))
|
||||
for i := range notes {
|
||||
item := map[string]any{
|
||||
"id": notes[i].ID,
|
||||
"content": notes[i].Content,
|
||||
"user_id": notes[i].UserID,
|
||||
"created_at": notes[i].CreatedAt.Unix(),
|
||||
"updated_at": notes[i].UpdatedAt.Unix(),
|
||||
}
|
||||
payload = append(payload, item)
|
||||
}
|
||||
return map[string]any{"payload": payload}
|
||||
}
|
||||
|
||||
func companyContactsCount(ctx context.Context, db *gorm.DB, company *model.Company) int64 {
|
||||
if db == nil || company == nil || company.ID == 0 {
|
||||
return 0
|
||||
}
|
||||
var count int64
|
||||
_ = db.WithContext(ctx).Model(&model.Contact{}).Where("account_id = ? AND company_id = ?", company.AccountID, company.ID).Count(&count).Error
|
||||
return count
|
||||
}
|
||||
|
||||
func unixTimePtr(value *time.Time) any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return value.Unix()
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/search"
|
||||
@@ -21,6 +20,10 @@ func NewCompanyRepo(db *gorm.DB) *CompanyRepo {
|
||||
return &CompanyRepo{db: db}
|
||||
}
|
||||
|
||||
func (r *CompanyRepo) DB() *gorm.DB {
|
||||
return r.db
|
||||
}
|
||||
|
||||
// FindByID retrieves a company by primary key.
|
||||
func (r *CompanyRepo) FindByID(ctx context.Context, id uint) (*model.Company, error) {
|
||||
var company model.Company
|
||||
@@ -111,22 +114,34 @@ func (r *CompanyRepo) ListContacts(ctx context.Context, companyID, accountID uin
|
||||
var contacts []model.Contact
|
||||
var total int64
|
||||
|
||||
// Count contacts in the join table that belong to the account
|
||||
countDB := r.db.WithContext(ctx).Model(&model.Contact{}).
|
||||
Joins("JOIN company_contacts ON company_contacts.contact_id = contacts.id").
|
||||
Where("company_contacts.company_id = ? AND contacts.account_id = ?", companyID, accountID)
|
||||
Where("company_id = ? AND account_id = ?", companyID, accountID)
|
||||
if err := countDB.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
err := r.db.WithContext(ctx).
|
||||
Joins("JOIN company_contacts ON company_contacts.contact_id = contacts.id").
|
||||
Where("company_contacts.company_id = ? AND contacts.account_id = ?", companyID, accountID).
|
||||
Offset(offset).Limit(limit).Order("contacts.id DESC").
|
||||
Where("company_id = ? AND account_id = ?", companyID, accountID).
|
||||
Offset(offset).Limit(limit).Order("name ASC, id ASC").
|
||||
Find(&contacts).Error
|
||||
return contacts, total, err
|
||||
}
|
||||
|
||||
func (r *CompanyRepo) SearchAssignableContacts(ctx context.Context, companyID, accountID uint, query string, offset, limit int) ([]model.Contact, int64, error) {
|
||||
var contacts []model.Contact
|
||||
var total int64
|
||||
likeQuery := "%" + query + "%"
|
||||
condition := r.db.WithContext(ctx).Model(&model.Contact{}).
|
||||
Where("account_id = ?", accountID).
|
||||
Where("company_id IS NULL OR company_id != ?", companyID).
|
||||
Where("LOWER(name) LIKE LOWER(?) OR LOWER(email) LIKE LOWER(?) OR LOWER(phone_number) LIKE LOWER(?) OR LOWER(identifier) LIKE LOWER(?)", likeQuery, likeQuery, likeQuery, likeQuery)
|
||||
if err := condition.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
err := condition.Offset(offset).Limit(limit).Order("name ASC, id ASC").Find(&contacts).Error
|
||||
return contacts, total, err
|
||||
}
|
||||
|
||||
// ListNotes retrieves notes for a company.
|
||||
func (r *CompanyRepo) ListNotes(ctx context.Context, companyID uint, offset, limit int) ([]model.CompanyNote, int64, error) {
|
||||
var notes []model.CompanyNote
|
||||
@@ -154,26 +169,12 @@ func (r *CompanyRepo) DeleteNote(ctx context.Context, id uint) error {
|
||||
return r.db.WithContext(ctx).Delete(&model.CompanyNote{}, id).Error
|
||||
}
|
||||
|
||||
// AddContact adds a contact to a company via the company_contacts join table.
|
||||
// Uses GORM's clause.OnConflict for dialect-aware upsert (works on both PG and SQLite).
|
||||
func (r *CompanyRepo) AddContact(ctx context.Context, companyID, contactID uint) error {
|
||||
association := struct {
|
||||
CompanyID uint `gorm:"primaryKey"`
|
||||
ContactID uint `gorm:"primaryKey"`
|
||||
}{
|
||||
CompanyID: companyID,
|
||||
ContactID: contactID,
|
||||
}
|
||||
return r.db.WithContext(ctx).Table("company_contacts").
|
||||
Clauses(clause.OnConflict{DoNothing: true}).
|
||||
Create(&association).Error
|
||||
return r.db.WithContext(ctx).Model(&model.Contact{}).Where("id = ?", contactID).Update("company_id", companyID).Error
|
||||
}
|
||||
|
||||
// RemoveContact removes a contact from a company via the company_contacts join table.
|
||||
func (r *CompanyRepo) RemoveContact(ctx context.Context, companyID, contactID uint) error {
|
||||
return r.db.WithContext(ctx).Table("company_contacts").
|
||||
Where("company_id = ? AND contact_id = ?", companyID, contactID).
|
||||
Delete(nil).Error
|
||||
return r.db.WithContext(ctx).Model(&model.Contact{}).Where("id = ? AND company_id = ?", contactID, companyID).Update("company_id", nil).Error
|
||||
}
|
||||
|
||||
// resolveCompanySort maps a sort parameter to a SQL ORDER BY clause.
|
||||
|
||||
@@ -265,6 +265,7 @@ func TestCompanyRepo_CreateNote(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
assert.NotZero(t, note.ID)
|
||||
}
|
||||
|
||||
// ========== DeleteNote Tests ==========
|
||||
|
||||
func TestCompanyRepo_DeleteNote(t *testing.T) {
|
||||
@@ -314,10 +315,10 @@ func TestCompanyRepo_AddContact(t *testing.T) {
|
||||
err := repo.AddContact(context.Background(), company.ID, contact.ID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify the association exists in the join table
|
||||
var count int64
|
||||
db.Table("company_contacts").Where("company_id = ? AND contact_id = ?", company.ID, contact.ID).Count(&count)
|
||||
assert.Equal(t, int64(1), count)
|
||||
var updated model.Contact
|
||||
require.NoError(t, db.First(&updated, contact.ID).Error)
|
||||
require.NotNil(t, updated.CompanyID)
|
||||
assert.Equal(t, company.ID, *updated.CompanyID)
|
||||
}
|
||||
|
||||
func TestCompanyRepo_AddContact_Duplicate(t *testing.T) {
|
||||
@@ -335,14 +336,14 @@ func TestCompanyRepo_AddContact_Duplicate(t *testing.T) {
|
||||
err := repo.AddContact(context.Background(), company.ID, contact.ID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Adding duplicate should succeed (ON CONFLICT DO NOTHING)
|
||||
// Assigning the same company twice should stay idempotent.
|
||||
err = repo.AddContact(context.Background(), company.ID, contact.ID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify only one association exists
|
||||
var count int64
|
||||
db.Table("company_contacts").Where("company_id = ? AND contact_id = ?", company.ID, contact.ID).Count(&count)
|
||||
assert.Equal(t, int64(1), count)
|
||||
var updated model.Contact
|
||||
require.NoError(t, db.First(&updated, contact.ID).Error)
|
||||
require.NotNil(t, updated.CompanyID)
|
||||
assert.Equal(t, company.ID, *updated.CompanyID)
|
||||
}
|
||||
|
||||
// ========== RemoveContact Tests ==========
|
||||
@@ -355,19 +356,15 @@ func TestCompanyRepo_RemoveContact(t *testing.T) {
|
||||
require.NoError(t, db.Create(account).Error)
|
||||
|
||||
company := createTestCompany(t, db, account.ID, "RemoveContactCorp", "removecontact.example.com")
|
||||
contact := &model.Contact{AccountID: account.ID, Name: "Remove Contact", Email: "remove@example.com"}
|
||||
contact := &model.Contact{AccountID: account.ID, Name: "Remove Contact", Email: "remove@example.com", CompanyID: &company.ID}
|
||||
require.NoError(t, db.Create(contact).Error)
|
||||
|
||||
// Create association via GORM many2many
|
||||
require.NoError(t, db.Model(&company).Association("Contacts").Append(contact))
|
||||
|
||||
err := repo.RemoveContact(context.Background(), company.ID, contact.ID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify the association is gone
|
||||
var count int64
|
||||
db.Table("company_contacts").Where("company_id = ? AND contact_id = ?", company.ID, contact.ID).Count(&count)
|
||||
assert.Equal(t, int64(0), count)
|
||||
var updated model.Contact
|
||||
require.NoError(t, db.First(&updated, contact.ID).Error)
|
||||
assert.Nil(t, updated.CompanyID)
|
||||
}
|
||||
|
||||
func TestCompanyRepo_RemoveContact_NotAssociated(t *testing.T) {
|
||||
|
||||
@@ -33,6 +33,10 @@ func (r *ContactRepo) FindByID(ctx context.Context, id uint) (*model.Contact, er
|
||||
return &contact, nil
|
||||
}
|
||||
|
||||
func (r *ContactRepo) DB() *gorm.DB {
|
||||
return r.db
|
||||
}
|
||||
|
||||
// FindByAccount retrieves all contacts for an account with optional sort.
|
||||
// sort: default "last_activity_at DESC, id DESC", alternatives: "name ASC", "email ASC", "created_at DESC"
|
||||
func (r *ContactRepo) FindByAccount(ctx context.Context, accountID uint, offset, limit int, sort string) ([]model.Contact, int64, error) {
|
||||
@@ -62,12 +66,12 @@ func (r *ContactRepo) Search(ctx context.Context, accountID uint, query string,
|
||||
if query != "" {
|
||||
if searchMode == search.SearchModeTrigram {
|
||||
// pg_trgm fuzzy match on contact fields
|
||||
condition = condition.Where("name % ? OR email % ? OR phone ILIKE ? OR identifier % ?",
|
||||
condition = condition.Where("name % ? OR email % ? OR phone_number ILIKE ? OR identifier % ?",
|
||||
query, query, query, query)
|
||||
} else {
|
||||
// ILIKE substring match (default)
|
||||
// Case-insensitive substring match that works on PostgreSQL and SQLite tests.
|
||||
likeQuery := "%" + query + "%"
|
||||
condition = condition.Where("name ILIKE ? OR email ILIKE ? OR phone ILIKE ? OR identifier ILIKE ?",
|
||||
condition = condition.Where("LOWER(name) LIKE LOWER(?) OR LOWER(email) LIKE LOWER(?) OR LOWER(phone_number) LIKE LOWER(?) OR LOWER(identifier) LIKE LOWER(?)",
|
||||
likeQuery, likeQuery, likeQuery, likeQuery)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -983,6 +983,7 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) {
|
||||
contacts.POST("/import", h.Contact.Import)
|
||||
contacts.GET("/:contact_id", h.Contact.Get)
|
||||
contacts.PUT("/:contact_id", h.Contact.Update)
|
||||
contacts.PATCH("/:contact_id", h.Contact.Update)
|
||||
contacts.DELETE("/:contact_id", h.Contact.Delete)
|
||||
|
||||
// M4 G3: Contact extension routes (active, export, import, contactable_inboxes, custom_attributes)
|
||||
@@ -1019,9 +1020,11 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) {
|
||||
companies.GET("/search", h.Company.Search)
|
||||
companies.GET("/:company_id", h.Company.Get)
|
||||
companies.PUT("/:company_id", h.Company.Update)
|
||||
companies.PATCH("/:company_id", h.Company.Update)
|
||||
companies.DELETE("/:company_id", h.Company.Delete)
|
||||
// Nested contacts under a company
|
||||
companies.GET("/:company_id/contacts", h.Company.ListContacts)
|
||||
companies.GET("/:company_id/contacts/search", h.Company.SearchContacts)
|
||||
// Nested conversations under a company (via contacts)
|
||||
companies.GET("/:company_id/conversations", h.Company.ListConversations)
|
||||
// Nested notes under a company
|
||||
@@ -1029,6 +1032,7 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) {
|
||||
companies.POST("/:company_id/notes", h.Company.CreateNote)
|
||||
companies.DELETE("/:company_id/notes/:note_id", h.Company.DeleteNote)
|
||||
// Nested contacts management under a company
|
||||
companies.POST("/:company_id/contacts", h.Company.AddContact)
|
||||
companies.POST("/:company_id/contacts/:contact_id", h.Company.AddContact)
|
||||
companies.DELETE("/:company_id/contacts/:contact_id", h.Company.RemoveContact)
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/repository"
|
||||
@@ -35,6 +37,13 @@ func (s *CompanyService) SetSearchIndexer(indexer SearchIndexer) {
|
||||
s.searchIndexer = indexer
|
||||
}
|
||||
|
||||
func (s *CompanyService) DB() *gorm.DB {
|
||||
if s == nil || s.companyRepo == nil {
|
||||
return nil
|
||||
}
|
||||
return s.companyRepo.DB()
|
||||
}
|
||||
|
||||
func (s *CompanyService) indexCompany(ctx context.Context, company *model.Company) {
|
||||
if s.searchIndexer != nil {
|
||||
logSearchIndexError("company", company.ID, s.searchIndexer.IndexCompany(ctx, company))
|
||||
@@ -55,6 +64,16 @@ type CreateCompanyRequest struct {
|
||||
FaviconURL string `json:"favicon_url,omitempty"`
|
||||
Domain string `json:"domain,omitempty"`
|
||||
CustomAttributes datatypes.JSON `json:"custom_attributes,omitempty"`
|
||||
Company *CompanyParams `json:"company,omitempty"`
|
||||
}
|
||||
|
||||
type CompanyParams struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
WebsiteURL string `json:"website_url,omitempty"`
|
||||
FaviconURL string `json:"favicon_url,omitempty"`
|
||||
Domain string `json:"domain,omitempty"`
|
||||
CustomAttributes datatypes.JSON `json:"custom_attributes,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateCompanyRequest is the DTO for updating a company.
|
||||
@@ -65,6 +84,7 @@ type UpdateCompanyRequest struct {
|
||||
FaviconURL string `json:"favicon_url,omitempty"`
|
||||
Domain string `json:"domain,omitempty"`
|
||||
CustomAttributes datatypes.JSON `json:"custom_attributes,omitempty"`
|
||||
Company *CompanyParams `json:"company,omitempty"`
|
||||
}
|
||||
|
||||
// CreateCompanyNoteRequest is the DTO for creating a company note.
|
||||
@@ -96,6 +116,17 @@ func (s *CompanyService) Get(ctx context.Context, id, accountID uint) (*model.Co
|
||||
|
||||
// Create creates a new company.
|
||||
func (s *CompanyService) Create(ctx context.Context, accountID uint, req *CreateCompanyRequest) (*model.Company, error) {
|
||||
if req.Company != nil {
|
||||
req.Name = req.Company.Name
|
||||
req.Description = req.Company.Description
|
||||
req.WebsiteURL = req.Company.WebsiteURL
|
||||
req.FaviconURL = req.Company.FaviconURL
|
||||
req.Domain = req.Company.Domain
|
||||
req.CustomAttributes = req.Company.CustomAttributes
|
||||
}
|
||||
if req.Name == "" {
|
||||
return nil, errors.New("name is required")
|
||||
}
|
||||
if err := pkgvalidator.ValidateStruct(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -120,6 +151,14 @@ func (s *CompanyService) Create(ctx context.Context, accountID uint, req *Create
|
||||
|
||||
// Update updates a company scoped to an account.
|
||||
func (s *CompanyService) Update(ctx context.Context, id, accountID uint, req *UpdateCompanyRequest) (*model.Company, error) {
|
||||
if req.Company != nil {
|
||||
req.Name = req.Company.Name
|
||||
req.Description = req.Company.Description
|
||||
req.WebsiteURL = req.Company.WebsiteURL
|
||||
req.FaviconURL = req.Company.FaviconURL
|
||||
req.Domain = req.Company.Domain
|
||||
req.CustomAttributes = req.Company.CustomAttributes
|
||||
}
|
||||
company, err := s.companyRepo.FindByIDAndAccount(ctx, id, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -141,7 +180,7 @@ func (s *CompanyService) Update(ctx context.Context, id, accountID uint, req *Up
|
||||
company.Domain = req.Domain
|
||||
}
|
||||
if req.CustomAttributes != nil {
|
||||
company.CustomAttributes = req.CustomAttributes
|
||||
company.CustomAttributes = mergeJSON(company.CustomAttributes, req.CustomAttributes)
|
||||
}
|
||||
|
||||
if err := pkgvalidator.ValidateStruct(company); err != nil {
|
||||
@@ -182,6 +221,25 @@ func (s *CompanyService) ListContacts(ctx context.Context, companyID, accountID
|
||||
return s.companyRepo.ListContacts(ctx, companyID, accountID, offset, limit)
|
||||
}
|
||||
|
||||
func (s *CompanyService) SearchContacts(ctx context.Context, companyID, accountID uint, query string, offset, limit int) ([]model.Contact, int64, error) {
|
||||
_, err := s.companyRepo.FindByIDAndAccount(ctx, companyID, accountID)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if query == "" {
|
||||
return nil, 0, errors.New("query is required")
|
||||
}
|
||||
return s.companyRepo.SearchAssignableContacts(ctx, companyID, accountID, query, offset, limit)
|
||||
}
|
||||
|
||||
func (s *CompanyService) GetContact(ctx context.Context, companyID, accountID, contactID uint) (*model.Contact, error) {
|
||||
_, err := s.companyRepo.FindByIDAndAccount(ctx, companyID, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.contactRepo.FindByAccountAndID(ctx, accountID, contactID)
|
||||
}
|
||||
|
||||
// ListConversations retrieves conversations for all contacts of a company.
|
||||
func (s *CompanyService) ListConversations(ctx context.Context, companyID, accountID uint, offset, limit int) ([]model.Conversation, int64, error) {
|
||||
// Verify company exists and belongs to the account
|
||||
@@ -279,6 +337,23 @@ func (s *CompanyService) AddContact(ctx context.Context, companyID, accountID, c
|
||||
return nil
|
||||
}
|
||||
|
||||
func mergeJSON(current datatypes.JSON, incoming datatypes.JSON) datatypes.JSON {
|
||||
if len(incoming) == 0 {
|
||||
return current
|
||||
}
|
||||
merged := map[string]any{}
|
||||
if len(current) > 0 {
|
||||
_ = json.Unmarshal(current, &merged)
|
||||
}
|
||||
incomingMap := map[string]any{}
|
||||
_ = json.Unmarshal(incoming, &incomingMap)
|
||||
for key, value := range incomingMap {
|
||||
merged[key] = value
|
||||
}
|
||||
bytes, _ := json.Marshal(merged)
|
||||
return datatypes.JSON(bytes)
|
||||
}
|
||||
|
||||
// RemoveContact removes a contact from a company.
|
||||
func (s *CompanyService) RemoveContact(ctx context.Context, companyID, accountID, contactID uint) error {
|
||||
// Verify company exists and belongs to the account
|
||||
|
||||
@@ -279,9 +279,7 @@ func TestCompanyService_ListContacts(t *testing.T) {
|
||||
contact1 := createTestContact(t, db, account.ID)
|
||||
contact2 := createTestContact(t, db, account.ID)
|
||||
|
||||
// Associate contacts with company via join table
|
||||
require.NoError(t, db.Exec("INSERT INTO company_contacts (company_id, contact_id) VALUES (?, ?)", company.ID, contact1.ID).Error)
|
||||
require.NoError(t, db.Exec("INSERT INTO company_contacts (company_id, contact_id) VALUES (?, ?)", company.ID, contact2.ID).Error)
|
||||
require.NoError(t, db.Model(&model.Contact{}).Where("id IN ?", []uint{contact1.ID, contact2.ID}).Update("company_id", company.ID).Error)
|
||||
|
||||
contacts, total, err := svc.ListContacts(context.Background(), company.ID, account.ID, 0, 10)
|
||||
require.NoError(t, err)
|
||||
@@ -314,7 +312,7 @@ func TestCompanyService_ListConversations(t *testing.T) {
|
||||
|
||||
// Create a contact and associate it with the company
|
||||
contact := createTestContact(t, db, account.ID)
|
||||
require.NoError(t, db.Exec("INSERT INTO company_contacts (company_id, contact_id) VALUES (?, ?)", company.ID, contact.ID).Error)
|
||||
require.NoError(t, db.Model(&model.Contact{}).Where("id = ?", contact.ID).Update("company_id", company.ID).Error)
|
||||
|
||||
// Create an inbox and conversation for the contact
|
||||
inbox := createTestInbox(t, db, account.ID, "web_widget")
|
||||
@@ -489,10 +487,10 @@ func TestCompanyService_AddContact(t *testing.T) {
|
||||
err = svc.AddContact(context.Background(), company.ID, account.ID, contact.ID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify association via join table
|
||||
var count int64
|
||||
db.Table("company_contacts").Where("company_id = ? AND contact_id = ?", company.ID, contact.ID).Count(&count)
|
||||
assert.Equal(t, int64(1), count)
|
||||
var updated model.Contact
|
||||
require.NoError(t, db.First(&updated, contact.ID).Error)
|
||||
require.NotNil(t, updated.CompanyID)
|
||||
assert.Equal(t, company.ID, *updated.CompanyID)
|
||||
}
|
||||
|
||||
func TestCompanyService_AddContact_WrongAccount(t *testing.T) {
|
||||
@@ -538,19 +536,15 @@ func TestCompanyService_RemoveContact(t *testing.T) {
|
||||
company, err := svc.Create(context.Background(), account.ID, req)
|
||||
require.NoError(t, err)
|
||||
|
||||
contact := &model.Contact{AccountID: account.ID, Name: "Test Contact", Email: "contact@example.com"}
|
||||
contact := &model.Contact{AccountID: account.ID, Name: "Test Contact", Email: "contact@example.com", CompanyID: &company.ID}
|
||||
require.NoError(t, db.Create(contact).Error)
|
||||
|
||||
// Add association first via GORM
|
||||
require.NoError(t, db.Model(&company).Association("Contacts").Append(contact))
|
||||
|
||||
err = svc.RemoveContact(context.Background(), company.ID, account.ID, contact.ID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify association is gone
|
||||
var count int64
|
||||
db.Table("company_contacts").Where("company_id = ? AND contact_id = ?", company.ID, contact.ID).Count(&count)
|
||||
assert.Equal(t, int64(0), count)
|
||||
var updated model.Contact
|
||||
require.NoError(t, db.First(&updated, contact.ID).Error)
|
||||
assert.Nil(t, updated.CompanyID)
|
||||
}
|
||||
|
||||
func TestCompanyService_RemoveContact_WrongAccount(t *testing.T) {
|
||||
@@ -585,4 +579,3 @@ func TestCompanyService_RemoveContact_ContactWrongAccount(t *testing.T) {
|
||||
err = svc.RemoveContact(context.Background(), company.ID, account1.ID, contact.ID)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,12 +3,14 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/repository"
|
||||
@@ -52,6 +54,13 @@ func (s *ContactService) Ready() bool {
|
||||
return s != nil && s.repo != nil
|
||||
}
|
||||
|
||||
func (s *ContactService) DB() *gorm.DB {
|
||||
if s == nil || s.repo == nil {
|
||||
return nil
|
||||
}
|
||||
return s.repo.DB()
|
||||
}
|
||||
|
||||
// ListByAccount retrieves all contacts for an account with optional sort.
|
||||
func (s *ContactService) ListByAccount(ctx context.Context, accountID uint, offset, limit int, sort string) ([]model.Contact, int64, error) {
|
||||
return s.repo.FindByAccount(ctx, accountID, offset, limit, sort)
|
||||
@@ -125,10 +134,10 @@ func (s *ContactService) Create(ctx context.Context, accountID uint, req CreateC
|
||||
}
|
||||
|
||||
if req.AdditionalAttributes != nil {
|
||||
contact.AdditionalAttributes = model.ToDatatypesJSON(req.AdditionalAttributes)
|
||||
contact.AdditionalAttributes = mergeContactJSON(contact.AdditionalAttributes, model.ToDatatypesJSON(req.AdditionalAttributes))
|
||||
}
|
||||
if req.CustomAttributes != nil {
|
||||
contact.CustomAttributes = model.ToDatatypesJSON(req.CustomAttributes)
|
||||
contact.CustomAttributes = mergeContactJSON(contact.CustomAttributes, model.ToDatatypesJSON(req.CustomAttributes))
|
||||
}
|
||||
|
||||
if err := s.repo.Create(ctx, contact); err != nil {
|
||||
@@ -444,6 +453,44 @@ func (s *ContactService) DeleteCustomAttributes(ctx context.Context, accountID,
|
||||
return s.repo.DeleteCustomAttributes(ctx, contact.ID)
|
||||
}
|
||||
|
||||
func (s *ContactService) DestroyCustomAttributes(ctx context.Context, accountID, contactID uint, keys []string) (*model.Contact, error) {
|
||||
contact, err := s.repo.FindByAccountAndID(ctx, accountID, contactID)
|
||||
if err != nil {
|
||||
return nil, errors.New("contact not found")
|
||||
}
|
||||
attrs := map[string]any{}
|
||||
if len(contact.CustomAttributes) > 0 {
|
||||
_ = json.Unmarshal(contact.CustomAttributes, &attrs)
|
||||
}
|
||||
for _, key := range keys {
|
||||
delete(attrs, key)
|
||||
}
|
||||
bytes, _ := json.Marshal(attrs)
|
||||
contact.CustomAttributes = datatypes.JSON(bytes)
|
||||
if err := s.repo.Update(ctx, contact); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.indexContact(ctx, contact)
|
||||
return contact, nil
|
||||
}
|
||||
|
||||
func mergeContactJSON(current datatypes.JSON, incoming datatypes.JSON) datatypes.JSON {
|
||||
if len(incoming) == 0 {
|
||||
return current
|
||||
}
|
||||
merged := map[string]any{}
|
||||
if len(current) > 0 {
|
||||
_ = json.Unmarshal(current, &merged)
|
||||
}
|
||||
incomingMap := map[string]any{}
|
||||
_ = json.Unmarshal(incoming, &incomingMap)
|
||||
for key, value := range incomingMap {
|
||||
merged[key] = value
|
||||
}
|
||||
bytes, _ := json.Marshal(merged)
|
||||
return datatypes.JSON(bytes)
|
||||
}
|
||||
|
||||
// ContactableInbox represents an inbox that a contact can be associated with.
|
||||
// Reference: Chatwoot contacts#contactable_inboxes
|
||||
type ContactableInbox struct {
|
||||
|
||||
Reference in New Issue
Block a user