feat(crm): align contact company payloads

This commit is contained in:
2026-06-05 02:34:06 +08:00
parent c7053d975d
commit 3481597c6f
13 changed files with 606 additions and 186 deletions
+5 -1
View File
@@ -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
+69 -15
View File
@@ -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
}
+58 -52
View File
@@ -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() {
+58 -25
View File
@@ -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() {
+192
View File
@@ -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()
}
+24 -23
View File
@@ -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.
+14 -17
View File
@@ -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) {
+7 -3
View File
@@ -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)
}
}
+4
View File
@@ -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)
}
+76 -1
View File
@@ -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
+10 -17
View File
@@ -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)
}
+49 -2
View File
@@ -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 {