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
+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()
}