From 3481597c6fab0ea70cccee194297d79e33d9c690 Mon Sep 17 00:00:00 2001 From: Rogee Date: Fri, 5 Jun 2026 02:34:06 +0800 Subject: [PATCH] feat(crm): align contact company payloads --- docs/parity/gochat_routes.txt | 6 +- internal/handler/api/v1/company_handler.go | 84 ++++++-- .../handler/api/v1/company_handler_test.go | 110 +++++----- internal/handler/api/v1/contact_handler.go | 83 +++++--- .../api/v1/contact_handler_crud_test.go | 70 ++++--- internal/handler/api/v1/crm_serializer.go | 192 ++++++++++++++++++ internal/repository/company_repo.go | 47 ++--- internal/repository/company_repo_test.go | 31 ++- internal/repository/contact_repo.go | 10 +- internal/router/router.go | 4 + internal/service/company_service.go | 77 ++++++- internal/service/company_service_test.go | 27 +-- internal/service/contact_service.go | 51 ++++- 13 files changed, 606 insertions(+), 186 deletions(-) create mode 100644 internal/handler/api/v1/crm_serializer.go diff --git a/docs/parity/gochat_routes.txt b/docs/parity/gochat_routes.txt index 392dea6f..9761c03e 100644 --- a/docs/parity/gochat_routes.txt +++ b/docs/parity/gochat_routes.txt @@ -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 diff --git a/internal/handler/api/v1/company_handler.go b/internal/handler/api/v1/company_handler.go index b1a9a909..1764a6a7 100644 --- a/internal/handler/api/v1/company_handler.go +++ b/internal/handler/api/v1/company_handler.go @@ -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"}) -} \ No newline at end of file + 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 +} diff --git a/internal/handler/api/v1/company_handler_test.go b/internal/handler/api/v1/company_handler_test.go index 9466b2d6..0b1c3656 100644 --- a/internal/handler/api/v1/company_handler_test.go +++ b/internal/handler/api/v1/company_handler_test.go @@ -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() { diff --git a/internal/handler/api/v1/contact_handler.go b/internal/handler/api/v1/contact_handler.go index b0e461aa..ce447647 100644 --- a/internal/handler/api/v1/contact_handler.go +++ b/internal/handler/api/v1/contact_handler.go @@ -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)) } diff --git a/internal/handler/api/v1/contact_handler_crud_test.go b/internal/handler/api/v1/contact_handler_crud_test.go index 5ec5c561..c3e98ee2 100644 --- a/internal/handler/api/v1/contact_handler_crud_test.go +++ b/internal/handler/api/v1/contact_handler_crud_test.go @@ -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() { diff --git a/internal/handler/api/v1/crm_serializer.go b/internal/handler/api/v1/crm_serializer.go new file mode 100644 index 00000000..545fcd9b --- /dev/null +++ b/internal/handler/api/v1/crm_serializer.go @@ -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() +} diff --git a/internal/repository/company_repo.go b/internal/repository/company_repo.go index 75772ce7..f11a1f43 100644 --- a/internal/repository/company_repo.go +++ b/internal/repository/company_repo.go @@ -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. diff --git a/internal/repository/company_repo_test.go b/internal/repository/company_repo_test.go index 10f4ba68..41da39b1 100644 --- a/internal/repository/company_repo_test.go +++ b/internal/repository/company_repo_test.go @@ -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) { diff --git a/internal/repository/contact_repo.go b/internal/repository/contact_repo.go index 7f72fc41..9ddadda9 100644 --- a/internal/repository/contact_repo.go +++ b/internal/repository/contact_repo.go @@ -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) } } diff --git a/internal/router/router.go b/internal/router/router.go index cc71aa79..c2686936 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -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) } diff --git a/internal/service/company_service.go b/internal/service/company_service.go index dafa1bde..4f04a940 100644 --- a/internal/service/company_service.go +++ b/internal/service/company_service.go @@ -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 diff --git a/internal/service/company_service_test.go b/internal/service/company_service_test.go index 88521d96..47650b9f 100644 --- a/internal/service/company_service_test.go +++ b/internal/service/company_service_test.go @@ -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) } - diff --git a/internal/service/contact_service.go b/internal/service/contact_service.go index 5eb19885..b8dfdf15 100644 --- a/internal/service/contact_service.go +++ b/internal/service/contact_service.go @@ -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 {