diff --git a/cmd/route_parity/main.go b/cmd/route_parity/main.go index be60796f..ba8b6e31 100644 --- a/cmd/route_parity/main.go +++ b/cmd/route_parity/main.go @@ -71,10 +71,17 @@ var criticalRoutes = []route{ {Method: "PUT", Path: "/api/v1/accounts/:account_id/contacts/:contact_id", Controller: "api/v1/accounts/contacts#update", Source: "routes.rb:197"}, {Method: "DELETE", Path: "/api/v1/accounts/:account_id/contacts/:contact_id", Controller: "api/v1/accounts/contacts#destroy", Source: "routes.rb:197"}, {Method: "GET", Path: "/api/v1/accounts/:account_id/contacts/search", Controller: "api/v1/accounts/contacts#search", Source: "routes.rb:201"}, + {Method: "GET", Path: "/api/v1/accounts/:account_id/contacts/active", Controller: "api/v1/accounts/contacts#active", Source: "routes.rb:200"}, {Method: "POST", Path: "/api/v1/accounts/:account_id/contacts/filter", Controller: "api/v1/accounts/contacts#filter", Source: "routes.rb:202"}, {Method: "POST", Path: "/api/v1/accounts/:account_id/contacts/import", Controller: "api/v1/accounts/contacts#import", Source: "routes.rb:203"}, {Method: "POST", Path: "/api/v1/accounts/:account_id/contacts/export", Controller: "api/v1/accounts/contacts#export", Source: "routes.rb:204"}, + {Method: "GET", Path: "/api/v1/accounts/:account_id/contacts/:contact_id/contactable_inboxes", Controller: "api/v1/accounts/contacts#contactable_inboxes", Source: "routes.rb:206"}, + {Method: "POST", Path: "/api/v1/accounts/:account_id/contacts/:contact_id/destroy_custom_attributes", Controller: "api/v1/accounts/contacts#destroy_custom_attributes", Source: "routes.rb:207"}, + {Method: "DELETE", Path: "/api/v1/accounts/:account_id/contacts/:contact_id/avatar", Controller: "api/v1/accounts/contacts#avatar", Source: "routes.rb:208"}, {Method: "GET", Path: "/api/v1/accounts/:account_id/contacts/:contact_id/conversations", Controller: "api/v1/accounts/contacts/conversations#index", Source: "routes.rb:211"}, + {Method: "POST", Path: "/api/v1/accounts/:account_id/contacts/:contact_id/contact_inboxes/", Controller: "api/v1/accounts/contacts/contact_inboxes#create", Source: "routes.rb:212"}, + {Method: "GET", Path: "/api/v1/accounts/:account_id/contacts/:contact_id/labels/", Controller: "api/v1/accounts/contacts/labels#index", Source: "routes.rb:213"}, + {Method: "POST", Path: "/api/v1/accounts/:account_id/contacts/:contact_id/labels/", Controller: "api/v1/accounts/contacts/labels#create", Source: "routes.rb:213"}, {Method: "GET", Path: "/api/v1/accounts/:account_id/contacts/:contact_id/notes", Controller: "api/v1/accounts/contacts/notes#index", Source: "routes.rb:214"}, {Method: "POST", Path: "/api/v1/accounts/:account_id/contacts/:contact_id/notes", Controller: "api/v1/accounts/contacts/notes#create", Source: "routes.rb:214"}, @@ -84,9 +91,12 @@ var criticalRoutes = []route{ {Method: "PUT", Path: "/api/v1/accounts/:account_id/companies/:company_id", Controller: "api/v1/accounts/companies#update", Source: "routes.rb:179"}, {Method: "DELETE", Path: "/api/v1/accounts/:account_id/companies/:company_id", Controller: "api/v1/accounts/companies#destroy", Source: "routes.rb:179"}, {Method: "GET", Path: "/api/v1/accounts/:account_id/companies/search", Controller: "api/v1/accounts/companies#search", Source: "routes.rb:181"}, + {Method: "POST", Path: "/api/v1/accounts/:account_id/companies/:company_id/destroy_custom_attributes", Controller: "api/v1/accounts/companies#destroy_custom_attributes", Source: "routes.rb:184"}, + {Method: "DELETE", Path: "/api/v1/accounts/:account_id/companies/:company_id/avatar", Controller: "api/v1/accounts/companies#avatar", Source: "routes.rb:185"}, {Method: "GET", Path: "/api/v1/accounts/:account_id/companies/:company_id/contacts", Controller: "api/v1/accounts/companies/contacts#index", Source: "routes.rb:188"}, - {Method: "POST", Path: "/api/v1/accounts/:account_id/companies/:company_id/contacts/:contact_id", Controller: "api/v1/accounts/companies/contacts#create", Source: "routes.rb:188"}, + {Method: "POST", Path: "/api/v1/accounts/:account_id/companies/:company_id/contacts", Controller: "api/v1/accounts/companies/contacts#create", Source: "routes.rb:188"}, {Method: "DELETE", Path: "/api/v1/accounts/:account_id/companies/:company_id/contacts/:contact_id", Controller: "api/v1/accounts/companies/contacts#destroy", Source: "routes.rb:188"}, + {Method: "GET", Path: "/api/v1/accounts/:account_id/companies/:company_id/contacts/search", Controller: "api/v1/accounts/companies/contacts#search", Source: "routes.rb:190"}, {Method: "GET", Path: "/api/v1/accounts/:account_id/companies/:company_id/conversations", Controller: "api/v1/accounts/companies/conversations#index", Source: "routes.rb:193"}, {Method: "GET", Path: "/api/v1/accounts/:account_id/companies/:company_id/notes", Controller: "api/v1/accounts/companies/notes#index", Source: "routes.rb:194"}, diff --git a/docs/parity/gochat_routes.txt b/docs/parity/gochat_routes.txt index 9761c03e..93b3374e 100644 --- a/docs/parity/gochat_routes.txt +++ b/docs/parity/gochat_routes.txt @@ -26,9 +26,11 @@ DELETE /api/v1/accounts/:account_id/captain/preferences/ DELETE /api/v1/accounts/:account_id/captain/scenarios/:scenario_id DELETE /api/v1/accounts/:account_id/channels/facebook_channel/:fb_id DELETE /api/v1/accounts/:account_id/companies/:company_id +DELETE /api/v1/accounts/:account_id/companies/:company_id/avatar DELETE /api/v1/accounts/:account_id/companies/:company_id/contacts/:contact_id DELETE /api/v1/accounts/:account_id/companies/:company_id/notes/:note_id DELETE /api/v1/accounts/:account_id/contacts/:contact_id +DELETE /api/v1/accounts/:account_id/contacts/:contact_id/avatar DELETE /api/v1/accounts/:account_id/contacts/:contact_id/contact_inboxes/:inbox_id DELETE /api/v1/accounts/:account_id/contacts/:contact_id/custom_attributes DELETE /api/v1/accounts/:account_id/contacts/:contact_id/custom_attributes/:attribute_name @@ -183,6 +185,8 @@ GET /api/v1/accounts/:account_id/contacts/:contact_id GET /api/v1/accounts/:account_id/contacts/:contact_id/contact_inboxes GET /api/v1/accounts/:account_id/contacts/:contact_id/contactable_inboxes GET /api/v1/accounts/:account_id/contacts/:contact_id/conversations +GET /api/v1/accounts/:account_id/contacts/:contact_id/labels +GET /api/v1/accounts/:account_id/contacts/:contact_id/labels/ GET /api/v1/accounts/:account_id/contacts/:contact_id/notes GET /api/v1/accounts/:account_id/contacts/:contact_id/notes/:note_id GET /api/v1/accounts/:account_id/contacts/active @@ -536,12 +540,16 @@ 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/destroy_custom_attributes POST /api/v1/accounts/:account_id/companies/:company_id/notes POST /api/v1/accounts/:account_id/contact_merge POST /api/v1/accounts/:account_id/contacts/ POST /api/v1/accounts/:account_id/contacts/:contact_id/contact_inboxes +POST /api/v1/accounts/:account_id/contacts/:contact_id/contact_inboxes/ POST /api/v1/accounts/:account_id/contacts/:contact_id/custom_attributes/ POST /api/v1/accounts/:account_id/contacts/:contact_id/destroy_custom_attributes +POST /api/v1/accounts/:account_id/contacts/:contact_id/labels +POST /api/v1/accounts/:account_id/contacts/:contact_id/labels/ POST /api/v1/accounts/:account_id/contacts/:contact_id/notes POST /api/v1/accounts/:account_id/contacts/export POST /api/v1/accounts/:account_id/contacts/filter @@ -807,4 +815,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: 809 +TOTAL: 817 diff --git a/docs/parity/route_parity.md b/docs/parity/route_parity.md index 3212f536..8b4f9c15 100644 --- a/docs/parity/route_parity.md +++ b/docs/parity/route_parity.md @@ -7,7 +7,7 @@ Generated from: This report covers tracked frontend-critical Chatwoot routes from `reference/chatwoot/config/routes.rb`, including API v1 account routes, Captain/Copilot, assignment policies, widget/public APIs, and API v2 reports. Ruby is not installed in the workspace, so Chatwoot routes are sourced from static route declarations instead of `bin/rails routes`. -Summary: 251 exact, 0 method-compatible, 0 parameter-compatible, 0 missing out of 251 tracked critical routes. +Summary: 261 exact, 0 method-compatible, 0 parameter-compatible, 0 missing out of 261 tracked critical routes. ## Missing Critical Routes @@ -47,8 +47,10 @@ These routes exist with equivalent method and path shape but different parameter | DELETE | `/api/v1/accounts/:account_id/captain/custom_tools/:tool_id` | `/api/v1/accounts/:account_id/captain/custom_tools/:tool_id` | `api/v1/accounts/captain/custom_tools#destroy` | `routes.rb:79` | exact | | DELETE | `/api/v1/accounts/:account_id/captain/documents/:document_id` | `/api/v1/accounts/:account_id/captain/documents/:document_id` | `api/v1/accounts/captain/documents#destroy` | `routes.rb:82` | exact | | DELETE | `/api/v1/accounts/:account_id/companies/:company_id` | `/api/v1/accounts/:account_id/companies/:company_id` | `api/v1/accounts/companies#destroy` | `routes.rb:179` | exact | +| DELETE | `/api/v1/accounts/:account_id/companies/:company_id/avatar` | `/api/v1/accounts/:account_id/companies/:company_id/avatar` | `api/v1/accounts/companies#avatar` | `routes.rb:185` | exact | | DELETE | `/api/v1/accounts/:account_id/companies/:company_id/contacts/:contact_id` | `/api/v1/accounts/:account_id/companies/:company_id/contacts/:contact_id` | `api/v1/accounts/companies/contacts#destroy` | `routes.rb:188` | exact | | DELETE | `/api/v1/accounts/:account_id/contacts/:contact_id` | `/api/v1/accounts/:account_id/contacts/:contact_id` | `api/v1/accounts/contacts#destroy` | `routes.rb:197` | exact | +| DELETE | `/api/v1/accounts/:account_id/contacts/:contact_id/avatar` | `/api/v1/accounts/:account_id/contacts/:contact_id/avatar` | `api/v1/accounts/contacts#avatar` | `routes.rb:208` | exact | | DELETE | `/api/v1/accounts/:account_id/conversations/:conversation_id` | `/api/v1/accounts/:account_id/conversations/:conversation_id` | `api/v1/accounts/conversations#destroy` | `routes.rb:134` | exact | | DELETE | `/api/v1/accounts/:account_id/conversations/:conversation_id/messages/:message_id` | `/api/v1/accounts/:account_id/conversations/:conversation_id/messages/:message_id` | `api/v1/accounts/conversations/messages#destroy` | `routes.rb:142` | exact | | DELETE | `/api/v1/accounts/:account_id/custom_attribute_definitions/:id` | `/api/v1/accounts/:account_id/custom_attribute_definitions/:id` | `api/v1/accounts/custom_attribute_definitions#destroy` | `routes.rb:250` | exact | @@ -94,13 +96,17 @@ These routes exist with equivalent method and path shape but different parameter | GET | `/api/v1/accounts/:account_id/companies/` | `/api/v1/accounts/:account_id/companies/` | `api/v1/accounts/companies#index` | `routes.rb:179` | exact | | GET | `/api/v1/accounts/:account_id/companies/:company_id` | `/api/v1/accounts/:account_id/companies/:company_id` | `api/v1/accounts/companies#show` | `routes.rb:179` | exact | | GET | `/api/v1/accounts/:account_id/companies/:company_id/contacts` | `/api/v1/accounts/:account_id/companies/:company_id/contacts` | `api/v1/accounts/companies/contacts#index` | `routes.rb:188` | exact | +| GET | `/api/v1/accounts/:account_id/companies/:company_id/contacts/search` | `/api/v1/accounts/:account_id/companies/:company_id/contacts/search` | `api/v1/accounts/companies/contacts#search` | `routes.rb:190` | exact | | GET | `/api/v1/accounts/:account_id/companies/:company_id/conversations` | `/api/v1/accounts/:account_id/companies/:company_id/conversations` | `api/v1/accounts/companies/conversations#index` | `routes.rb:193` | exact | | GET | `/api/v1/accounts/:account_id/companies/:company_id/notes` | `/api/v1/accounts/:account_id/companies/:company_id/notes` | `api/v1/accounts/companies/notes#index` | `routes.rb:194` | exact | | GET | `/api/v1/accounts/:account_id/companies/search` | `/api/v1/accounts/:account_id/companies/search` | `api/v1/accounts/companies#search` | `routes.rb:181` | exact | | GET | `/api/v1/accounts/:account_id/contacts/` | `/api/v1/accounts/:account_id/contacts/` | `api/v1/accounts/contacts#index` | `routes.rb:197` | exact | | GET | `/api/v1/accounts/:account_id/contacts/:contact_id` | `/api/v1/accounts/:account_id/contacts/:contact_id` | `api/v1/accounts/contacts#show` | `routes.rb:197` | exact | +| GET | `/api/v1/accounts/:account_id/contacts/:contact_id/contactable_inboxes` | `/api/v1/accounts/:account_id/contacts/:contact_id/contactable_inboxes` | `api/v1/accounts/contacts#contactable_inboxes` | `routes.rb:206` | exact | | GET | `/api/v1/accounts/:account_id/contacts/:contact_id/conversations` | `/api/v1/accounts/:account_id/contacts/:contact_id/conversations` | `api/v1/accounts/contacts/conversations#index` | `routes.rb:211` | exact | +| GET | `/api/v1/accounts/:account_id/contacts/:contact_id/labels/` | `/api/v1/accounts/:account_id/contacts/:contact_id/labels/` | `api/v1/accounts/contacts/labels#index` | `routes.rb:213` | exact | | GET | `/api/v1/accounts/:account_id/contacts/:contact_id/notes` | `/api/v1/accounts/:account_id/contacts/:contact_id/notes` | `api/v1/accounts/contacts/notes#index` | `routes.rb:214` | exact | +| GET | `/api/v1/accounts/:account_id/contacts/active` | `/api/v1/accounts/:account_id/contacts/active` | `api/v1/accounts/contacts#active` | `routes.rb:200` | exact | | GET | `/api/v1/accounts/:account_id/contacts/search` | `/api/v1/accounts/:account_id/contacts/search` | `api/v1/accounts/contacts#search` | `routes.rb:201` | exact | | GET | `/api/v1/accounts/:account_id/conversations/` | `/api/v1/accounts/:account_id/conversations/` | `api/v1/accounts/conversations#index` | `routes.rb:134` | exact | | GET | `/api/v1/accounts/:account_id/conversations/:conversation_id` | `/api/v1/accounts/:account_id/conversations/:conversation_id` | `api/v1/accounts/conversations#show` | `routes.rb:134` | exact | @@ -202,8 +208,12 @@ These routes exist with equivalent method and path shape but different parameter | POST | `/api/v1/accounts/:account_id/captain/tasks/rewrite` | `/api/v1/accounts/:account_id/captain/tasks/rewrite` | `api/v1/accounts/captain/tasks#rewrite` | `routes.rb:86` | exact | | POST | `/api/v1/accounts/:account_id/captain/tasks/summarize` | `/api/v1/accounts/:account_id/captain/tasks/summarize` | `api/v1/accounts/captain/tasks#summarize` | `routes.rb:87` | exact | | POST | `/api/v1/accounts/:account_id/companies/` | `/api/v1/accounts/:account_id/companies/` | `api/v1/accounts/companies#create` | `routes.rb:179` | exact | -| POST | `/api/v1/accounts/:account_id/companies/:company_id/contacts/:contact_id` | `/api/v1/accounts/:account_id/companies/:company_id/contacts/:contact_id` | `api/v1/accounts/companies/contacts#create` | `routes.rb:188` | exact | +| POST | `/api/v1/accounts/:account_id/companies/:company_id/contacts` | `/api/v1/accounts/:account_id/companies/:company_id/contacts` | `api/v1/accounts/companies/contacts#create` | `routes.rb:188` | exact | +| POST | `/api/v1/accounts/:account_id/companies/:company_id/destroy_custom_attributes` | `/api/v1/accounts/:account_id/companies/:company_id/destroy_custom_attributes` | `api/v1/accounts/companies#destroy_custom_attributes` | `routes.rb:184` | exact | | POST | `/api/v1/accounts/:account_id/contacts/` | `/api/v1/accounts/:account_id/contacts/` | `api/v1/accounts/contacts#create` | `routes.rb:197` | exact | +| POST | `/api/v1/accounts/:account_id/contacts/:contact_id/contact_inboxes/` | `/api/v1/accounts/:account_id/contacts/:contact_id/contact_inboxes/` | `api/v1/accounts/contacts/contact_inboxes#create` | `routes.rb:212` | exact | +| POST | `/api/v1/accounts/:account_id/contacts/:contact_id/destroy_custom_attributes` | `/api/v1/accounts/:account_id/contacts/:contact_id/destroy_custom_attributes` | `api/v1/accounts/contacts#destroy_custom_attributes` | `routes.rb:207` | exact | +| POST | `/api/v1/accounts/:account_id/contacts/:contact_id/labels/` | `/api/v1/accounts/:account_id/contacts/:contact_id/labels/` | `api/v1/accounts/contacts/labels#create` | `routes.rb:213` | exact | | POST | `/api/v1/accounts/:account_id/contacts/:contact_id/notes` | `/api/v1/accounts/:account_id/contacts/:contact_id/notes` | `api/v1/accounts/contacts/notes#create` | `routes.rb:214` | exact | | POST | `/api/v1/accounts/:account_id/contacts/export` | `/api/v1/accounts/:account_id/contacts/export` | `api/v1/accounts/contacts#export` | `routes.rb:204` | exact | | POST | `/api/v1/accounts/:account_id/contacts/filter` | `/api/v1/accounts/:account_id/contacts/filter` | `api/v1/accounts/contacts#filter` | `routes.rb:202` | exact | diff --git a/internal/handler/api/v1/company_handler.go b/internal/handler/api/v1/company_handler.go index 1764a6a7..3ea6ea72 100644 --- a/internal/handler/api/v1/company_handler.go +++ b/internal/handler/api/v1/company_handler.go @@ -175,6 +175,55 @@ func (h *CompanyHandler) Delete(c *gin.Context) { c.Status(http.StatusOK) } +func (h *CompanyHandler) DestroyCustomAttributes(c *gin.Context) { + accountID := getAccountID(c) + if accountID == 0 { + response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "account not identified") + return + } + companyID, err := strconv.ParseUint(c.Param("company_id"), 10, 32) + if err != nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid company id") + return + } + var req struct { + CustomAttributes []string `json:"custom_attributes"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if req.CustomAttributes == nil { + c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "custom_attributes must be an array"}) + return + } + company, svcErr := h.svc.DestroyCustomAttributes(c.Request.Context(), uint(companyID), accountID, req.CustomAttributes) + if svcErr != nil { + handleServiceError(c, svcErr) + return + } + c.JSON(http.StatusOK, companyPayloadResponse(c.Request.Context(), h.svc.DB(), company)) +} + +func (h *CompanyHandler) DeleteAvatar(c *gin.Context) { + accountID := getAccountID(c) + if accountID == 0 { + response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "account not identified") + return + } + companyID, err := strconv.ParseUint(c.Param("company_id"), 10, 32) + if err != nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid company id") + return + } + company, svcErr := h.svc.DeleteAvatar(c.Request.Context(), uint(companyID), accountID) + if svcErr != nil { + handleServiceError(c, svcErr) + return + } + c.JSON(http.StatusOK, companyPayloadResponse(c.Request.Context(), h.svc.DB(), company)) +} + // ListContacts retrieves contacts associated with a company. // GET /api/v1/accounts/:id/companies/:company_id/contacts?page=1&per_page=25 func (h *CompanyHandler) ListContacts(c *gin.Context) { diff --git a/internal/handler/api/v1/company_handler_test.go b/internal/handler/api/v1/company_handler_test.go index 0b1c3656..e4a0edfc 100644 --- a/internal/handler/api/v1/company_handler_test.go +++ b/internal/handler/api/v1/company_handler_test.go @@ -12,6 +12,7 @@ import ( "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" + "gorm.io/datatypes" "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" @@ -103,6 +104,8 @@ func (s *CompanyHandlerTestSuite) SetupSuite() { companies.PUT("/:company_id", s.handler.Update) companies.PATCH("/:company_id", s.handler.Update) companies.DELETE("/:company_id", s.handler.Delete) + companies.POST("/:company_id/destroy_custom_attributes", s.handler.DestroyCustomAttributes) + companies.DELETE("/:company_id/avatar", s.handler.DeleteAvatar) companies.GET("/:company_id/contacts", s.handler.ListContacts) companies.GET("/:company_id/contacts/search", s.handler.SearchContacts) companies.POST("/:company_id/contacts", s.handler.AddContact) @@ -292,6 +295,55 @@ func (s *CompanyHandlerTestSuite) TestDelete_InvalidID() { "Expected 400 or 404 for invalid company ID, got %d", w.Code) } +func (s *CompanyHandlerTestSuite) TestDestroyCustomAttributes_Success() { + companyRepo := repository.NewCompanyRepo(s.db) + company := &model.Company{ + AccountID: s.accountID, + Name: "AttrCorp", + CustomAttributes: datatypes.JSON([]byte(`{"plan":"pro","tier":"gold"}`)), + } + s.Require().NoError(companyRepo.Create(context.Background(), company)) + + w := s.makeRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/companies/%d/destroy_custom_attributes", s.accountID, company.ID), map[string]interface{}{ + "custom_attributes": []string{"tier"}, + }) + assert.Equal(s.T(), http.StatusOK, w.Code) + + var resp map[string]interface{} + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + payload := resp["payload"].(map[string]interface{}) + attrs := payload["custom_attributes"].(map[string]interface{}) + assert.Equal(s.T(), "pro", attrs["plan"]) + assert.NotContains(s.T(), attrs, "tier") +} + +func (s *CompanyHandlerTestSuite) TestDestroyCustomAttributes_RequiresArray() { + companyRepo := repository.NewCompanyRepo(s.db) + company := &model.Company{AccountID: s.accountID, Name: "AttrInvalidCorp"} + s.Require().NoError(companyRepo.Create(context.Background(), company)) + + w := s.makeRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/companies/%d/destroy_custom_attributes", s.accountID, company.ID), map[string]interface{}{}) + assert.Equal(s.T(), http.StatusUnprocessableEntity, w.Code) +} + +func (s *CompanyHandlerTestSuite) TestDeleteAvatar_Success() { + companyRepo := repository.NewCompanyRepo(s.db) + company := &model.Company{AccountID: s.accountID, Name: "AvatarCorp", FaviconURL: "https://example.com/avatar.png"} + s.Require().NoError(companyRepo.Create(context.Background(), company)) + + w := s.makeRequest("DELETE", fmt.Sprintf("/api/v1/accounts/%d/companies/%d/avatar", s.accountID, company.ID), nil) + assert.Equal(s.T(), http.StatusOK, w.Code) + + var resp map[string]interface{} + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + payload := resp["payload"].(map[string]interface{}) + assert.Equal(s.T(), "", payload["avatar_url"]) + + var found model.Company + s.Require().NoError(s.db.First(&found, company.ID).Error) + assert.Equal(s.T(), "", found.FaviconURL) +} + // ========== ListContacts ========== func (s *CompanyHandlerTestSuite) TestListContacts_Success() { diff --git a/internal/handler/api/v1/contact_handler.go b/internal/handler/api/v1/contact_handler.go index ce447647..953188b2 100644 --- a/internal/handler/api/v1/contact_handler.go +++ b/internal/handler/api/v1/contact_handler.go @@ -63,7 +63,7 @@ func (h *ContactHandler) List(c *gin.Context) { offset := (page - 1) * perPage sort := c.DefaultQuery("sort", "") - contacts, total, svcErr := h.svc.ListByAccount(c.Request.Context(), accountID, offset, perPage, sort) + contacts, total, svcErr := h.svc.ListByAccount(c.Request.Context(), accountID, offset, perPage, sort, contactLabelsParam(c)) if svcErr != nil { c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to list contacts"}) return @@ -110,7 +110,7 @@ func (h *ContactHandler) Search(c *gin.Context) { sort := c.DefaultQuery("sort", "") searchMode := search.ParseSearchMode(c.DefaultQuery("search_mode", "")) - contacts, total, svcErr := h.svc.Search(c.Request.Context(), accountID, query, offset, perPage, sort, searchMode) + contacts, total, svcErr := h.svc.Search(c.Request.Context(), accountID, query, offset, perPage, sort, searchMode, contactLabelsParam(c)) if svcErr != nil { c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to search contacts"}) return @@ -286,6 +286,70 @@ func (h *ContactHandler) Delete(c *gin.Context) { c.Status(http.StatusOK) } +func (h *ContactHandler) DeleteAvatar(c *gin.Context) { + accountID := parseAccountIDParam(c) + if accountID == 0 { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id") + return + } + contactID, err := parseUintParam(c, "contact_id") + if err != nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid contact id") + return + } + contact, svcErr := h.svc.DeleteAvatar(c.Request.Context(), accountID, contactID) + if svcErr != nil { + handleServiceError(c, svcErr) + return + } + c.JSON(http.StatusOK, contactPayloadResponse(c.Request.Context(), h.svc.DB(), contact, false)) +} + +func (h *ContactHandler) ListLabels(c *gin.Context) { + accountID := parseAccountIDParam(c) + if accountID == 0 { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id") + return + } + contactID, err := parseUintParam(c, "contact_id") + if err != nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid contact id") + return + } + labels, svcErr := h.svc.GetLabels(c.Request.Context(), accountID, contactID) + if svcErr != nil { + handleServiceError(c, svcErr) + return + } + c.JSON(http.StatusOK, gin.H{"payload": labels}) +} + +func (h *ContactHandler) UpdateLabels(c *gin.Context) { + accountID := parseAccountIDParam(c) + if accountID == 0 { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id") + return + } + contactID, err := parseUintParam(c, "contact_id") + if err != nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid contact id") + return + } + var req struct { + Labels []string `json:"labels"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) + return + } + labels, svcErr := h.svc.UpdateLabels(c.Request.Context(), accountID, contactID, req.Labels) + if svcErr != nil { + handleServiceError(c, svcErr) + return + } + c.JSON(http.StatusOK, gin.H{"payload": labels}) +} + // ListContactInboxes retrieves all contact_inboxes for a contact. // GET /api/v1/accounts/:id/contacts/:contact_id/contact_inboxes // Reference: Chatwoot contacts#contact_inboxes (nested resource) @@ -503,6 +567,12 @@ func includeContactInboxes(c *gin.Context) bool { return true } +func contactLabelsParam(c *gin.Context) []string { + labels := c.QueryArray("labels[]") + labels = append(labels, c.QueryArray("labels")...) + return labels +} + // parseIntOrDefault parses an integer query parameter with a default value. func parseIntOrDefault(c *gin.Context, key string, defaultVal int) int { val := c.Query(key) diff --git a/internal/handler/api/v1/contact_handler_crud_test.go b/internal/handler/api/v1/contact_handler_crud_test.go index c3e98ee2..70bc5fa4 100644 --- a/internal/handler/api/v1/contact_handler_crud_test.go +++ b/internal/handler/api/v1/contact_handler_crud_test.go @@ -49,6 +49,8 @@ func (s *ContactHandlerCRUDTestSuite) SetupSuite() { &model.AccountUser{}, &model.Inbox{}, &model.Contact{}, + &model.Tag{}, + &model.ContactLabel{}, &model.Conversation{}, &model.ContactInbox{}, &model.InboxMember{}, @@ -83,7 +85,10 @@ func (s *ContactHandlerCRUDTestSuite) SetupSuite() { s.router.POST("/api/v1/accounts/:id/contacts", s.handler.Create) s.router.PUT("/api/v1/accounts/:id/contacts/:contact_id", s.handler.Update) s.router.DELETE("/api/v1/accounts/:id/contacts/:contact_id", s.handler.Delete) + s.router.DELETE("/api/v1/accounts/:id/contacts/:contact_id/avatar", s.handler.DeleteAvatar) s.router.GET("/api/v1/accounts/:id/contacts/:contact_id/conversations", s.handler.ListConversations) + s.router.GET("/api/v1/accounts/:id/contacts/:contact_id/labels", s.handler.ListLabels) + s.router.POST("/api/v1/accounts/:id/contacts/:contact_id/labels", s.handler.UpdateLabels) s.router.GET("/api/v1/accounts/:id/contacts/:contact_id/contact_inboxes", s.handler.ListContactInboxes) s.router.GET("/api/v1/accounts/:id/contacts/:contact_id/notes", s.handler.ListNotes) s.router.POST("/api/v1/accounts/:id/contacts/:contact_id/notes", s.handler.CreateNote) @@ -111,6 +116,8 @@ func (s *ContactHandlerCRUDTestSuite) SetupSuite() { // SetupTest re-creates core test data before each test so tests don't leak state. func (s *ContactHandlerCRUDTestSuite) SetupTest() { s.db.Exec("DELETE FROM contact_notes") + s.db.Exec("DELETE FROM contact_labels") + s.db.Exec("DELETE FROM tags") s.db.Exec("DELETE FROM notes") s.db.Exec("DELETE FROM contact_inboxes") s.db.Exec("DELETE FROM conversations") @@ -532,6 +539,63 @@ func (s *ContactHandlerCRUDTestSuite) TestDelete_Success() { s.Error(err, "contact should be soft-deleted") } +func (s *ContactHandlerCRUDTestSuite) TestDeleteAvatar_Success() { + s.Require().NoError(s.db.Model(s.contact).Update("avatar_url", "https://example.com/avatar.png").Error) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("DELETE", + fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/avatar", s.account.ID, s.contact.ID), nil) + s.router.ServeHTTP(w, req) + + s.Equal(http.StatusOK, w.Code) + + var resp map[string]interface{} + s.NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + payload := resp["payload"].(map[string]interface{}) + s.Equal("", payload["thumbnail"]) + + var found model.Contact + s.Require().NoError(s.db.First(&found, s.contact.ID).Error) + s.Equal("", found.AvatarURL) +} + +func (s *ContactHandlerCRUDTestSuite) TestLabels_UpdateListAndFilter() { + bodyBytes, _ := json.Marshal(map[string]interface{}{"labels": []string{"vip", "trial"}}) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", + fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/labels", s.account.ID, s.contact.ID), + bytes.NewReader(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + s.router.ServeHTTP(w, req) + + s.Equal(http.StatusOK, w.Code) + var resp map[string]interface{} + s.NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + s.ElementsMatch([]interface{}{"vip", "trial"}, resp["payload"]) + + w = httptest.NewRecorder() + req, _ = http.NewRequest("GET", + fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/labels", s.account.ID, s.contact.ID), nil) + s.router.ServeHTTP(w, req) + s.Equal(http.StatusOK, w.Code) + s.NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + s.ElementsMatch([]interface{}{"vip", "trial"}, resp["payload"]) + + other := &model.Contact{AccountID: s.account.ID, Name: "Other Contact"} + s.Require().NoError(s.db.Create(other).Error) + + w = httptest.NewRecorder() + req, _ = http.NewRequest("GET", + fmt.Sprintf("/api/v1/accounts/%d/contacts?labels%%5B%%5D=vip&page=1&page_size=25", s.account.ID), nil) + s.router.ServeHTTP(w, req) + s.Equal(http.StatusOK, w.Code) + s.NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + payload := resp["payload"].([]interface{}) + s.Len(payload, 1) + s.Equal(float64(s.contact.ID), payload[0].(map[string]interface{})["id"]) +} + func (s *ContactHandlerCRUDTestSuite) TestDelete_InvalidAccountID() { w := httptest.NewRecorder() req, _ := http.NewRequest("DELETE", diff --git a/internal/model/contact_label.go b/internal/model/contact_label.go new file mode 100644 index 00000000..884d5874 --- /dev/null +++ b/internal/model/contact_label.go @@ -0,0 +1,18 @@ +package model + +import "time" + +// ContactLabel stores the account label/tag assignments for contacts. +// Reference: Chatwoot Contact includes Labelable and uses label_list. +type ContactLabel struct { + ContactID uint `gorm:"primaryKey;autoIncrement:false;index" json:"contact_id"` + TagID uint `gorm:"primaryKey;autoIncrement:false;index" json:"tag_id"` + AccountID uint `gorm:"not null;index" json:"account_id"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + + Contact Contact `gorm:"foreignKey:ContactID" json:"contact,omitempty"` + Tag Tag `gorm:"foreignKey:TagID" json:"tag,omitempty"` +} + +func (ContactLabel) TableName() string { return "contact_labels" } diff --git a/internal/repository/contact_repo.go b/internal/repository/contact_repo.go index 9ddadda9..ba0bb57d 100644 --- a/internal/repository/contact_repo.go +++ b/internal/repository/contact_repo.go @@ -39,29 +39,42 @@ func (r *ContactRepo) DB() *gorm.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) { +func (r *ContactRepo) FindByAccount(ctx context.Context, accountID uint, offset, limit int, sort string, labels ...[]string) ([]model.Contact, int64, error) { var contacts []model.Contact var total int64 - countDB := r.db.WithContext(ctx).Model(&model.Contact{}).Where("account_id = ?", accountID) + labelFilter := firstContactLabelFilter(labels) + base := r.db.WithContext(ctx).Model(&model.Contact{}).Where("contacts.account_id = ?", accountID) + base = applyContactLabelFilter(base, accountID, labelFilter) + + countDB := base + if len(labelFilter) > 0 { + countDB = countDB.Distinct("contacts.id") + } if err := countDB.Count(&total).Error; err != nil { return nil, 0, err } orderClause := resolveContactSort(sort) - err := r.db.WithContext(ctx).Where("account_id = ?", accountID). + queryDB := base + if len(labelFilter) > 0 { + queryDB = queryDB.Distinct("contacts.*") + } + err := queryDB. Offset(offset).Limit(limit).Order(orderClause). Find(&contacts).Error return contacts, total, err } // Search searches contacts by name, email, or phone number with optional sort. -func (r *ContactRepo) Search(ctx context.Context, accountID uint, query string, offset, limit int, sort string, searchMode search.SearchMode) ([]model.Contact, int64, error) { +func (r *ContactRepo) Search(ctx context.Context, accountID uint, query string, offset, limit int, sort string, searchMode search.SearchMode, labels ...[]string) ([]model.Contact, int64, error) { var contacts []model.Contact var total int64 condition := r.db.WithContext(ctx).Model(&model.Contact{}). - Where("account_id = ?", accountID) + Where("contacts.account_id = ?", accountID) + labelFilter := firstContactLabelFilter(labels) + condition = applyContactLabelFilter(condition, accountID, labelFilter) if query != "" { if searchMode == search.SearchModeTrigram { @@ -76,31 +89,73 @@ func (r *ContactRepo) Search(ctx context.Context, accountID uint, query string, } } - if err := condition.Count(&total).Error; err != nil { + countDB := condition + if len(labelFilter) > 0 { + countDB = countDB.Distinct("contacts.id") + } + if err := countDB.Count(&total).Error; err != nil { return nil, 0, err } orderClause := resolveContactSort(sort) - err := condition.Offset(offset).Limit(limit).Order(orderClause). + queryDB := condition + if len(labelFilter) > 0 { + queryDB = queryDB.Distinct("contacts.*") + } + err := queryDB.Offset(offset).Limit(limit).Order(orderClause). Find(&contacts).Error return contacts, total, err } +func firstContactLabelFilter(filters [][]string) []string { + if len(filters) == 0 { + return nil + } + return normalizeContactLabels(filters[0]) +} + +func normalizeContactLabels(labels []string) []string { + seen := map[string]struct{}{} + result := make([]string, 0, len(labels)) + for _, label := range labels { + label = strings.TrimSpace(label) + if label == "" { + continue + } + if _, ok := seen[label]; ok { + continue + } + seen[label] = struct{}{} + result = append(result, label) + } + return result +} + +func applyContactLabelFilter(q *gorm.DB, accountID uint, labels []string) *gorm.DB { + labels = normalizeContactLabels(labels) + if len(labels) == 0 { + return q + } + return q.Joins("JOIN contact_labels ON contact_labels.contact_id = contacts.id"). + Joins("JOIN tags ON tags.id = contact_labels.tag_id"). + Where("contact_labels.account_id = ? AND tags.account_id = ? AND tags.name IN ?", accountID, accountID, labels) +} + // resolveContactSort maps a sort parameter to a SQL ORDER BY clause. // Supported values: "name", "email", "created_at", "last_activity_at" (default). // Direction defaults to DESC for date fields, ASC for name/email. func resolveContactSort(sort string) string { allowedSorts := map[string]string{ - "name": "name ASC", - "email": "email ASC", - "created_at": "created_at DESC", - "last_activity_at": "last_activity_at DESC NULLS LAST, id DESC", + "name": "contacts.name ASC", + "email": "contacts.email ASC", + "created_at": "contacts.created_at DESC", + "last_activity_at": "contacts.last_activity_at DESC NULLS LAST, contacts.id DESC", } if clause, ok := allowedSorts[sort]; ok { return clause } - return "last_activity_at DESC NULLS LAST, id DESC" + return "contacts.last_activity_at DESC NULLS LAST, contacts.id DESC" } // FindByAccountAndID retrieves a contact scoped to an account. @@ -221,7 +276,8 @@ func (r *ContactRepo) Filter(ctx context.Context, accountID uint, params Contact var contacts []model.Contact var total int64 - q := r.db.WithContext(ctx).Model(&model.Contact{}).Where("account_id = ?", accountID) + q := r.db.WithContext(ctx).Model(&model.Contact{}).Where("contacts.account_id = ?", accountID) + usesDistinct := false // Apply contact type filter if params.ContactType != "" { @@ -236,14 +292,13 @@ func (r *ContactRepo) Filter(ctx context.Context, accountID uint, params Contact // Apply inbox filter via contact_inboxes join if params.InboxID != nil { q = q.Joins("JOIN contact_inboxes ON contact_inboxes.contact_id = contacts.id AND contact_inboxes.inbox_id = ?", *params.InboxID) + usesDistinct = true } - // Apply labels filter (contacts with conversations having matching labels) + // Apply labels filter (Chatwoot: contact label_list tagged_with any: true) if params.Labels != "" { - labelList := strings.Split(params.Labels, ",") - for _, label := range labelList { - q = q.Joins("JOIN conversations ON conversations.contact_id = contacts.id AND conversations.labels LIKE ?", "%"+label+"%") - } + q = applyContactLabelFilter(q, accountID, strings.Split(params.Labels, ",")) + usesDistinct = true } // Apply status filter (active = has recent activity, inactive = no recent activity) @@ -260,12 +315,20 @@ func (r *ContactRepo) Filter(ctx context.Context, accountID uint, params Contact } // Count total matching records - if err := q.Count(&total).Error; err != nil { + countDB := q + if usesDistinct { + countDB = countDB.Distinct("contacts.id") + } + if err := countDB.Count(&total).Error; err != nil { return nil, 0, err } orderClause := resolveContactSort(params.Sort) - err := q.Offset(offset).Limit(limit).Order(orderClause). + queryDB := q + if usesDistinct { + queryDB = queryDB.Distinct("contacts.*") + } + err := queryDB.Offset(offset).Limit(limit).Order(orderClause). Find(&contacts).Error return contacts, total, err } diff --git a/internal/repository/testdb_helper.go b/internal/repository/testdb_helper.go index 11a5d9b3..77c39d73 100644 --- a/internal/repository/testdb_helper.go +++ b/internal/repository/testdb_helper.go @@ -151,6 +151,7 @@ func defaultTestModels() []interface{} { &csat.CsatSurveyResponse{}, &model.Tag{}, &model.ConversationLabel{}, + &model.ContactLabel{}, &model.PlatformApp{}, &model.Permissible{}, &model.AccessToken{}, diff --git a/internal/router/router.go b/internal/router/router.go index c2686936..675d7c38 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -985,10 +985,15 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) { contacts.PUT("/:contact_id", h.Contact.Update) contacts.PATCH("/:contact_id", h.Contact.Update) contacts.DELETE("/:contact_id", h.Contact.Delete) + contacts.DELETE("/:contact_id/avatar", h.Contact.DeleteAvatar) // M4 G3: Contact extension routes (active, export, import, contactable_inboxes, custom_attributes) contacts.GET("/:contact_id/conversations", h.Contact.ListConversations) contacts.GET("/:contact_id/contactable_inboxes", h.Contact.ContactableInboxes) + contacts.GET("/:contact_id/labels", h.Contact.ListLabels) + contacts.GET("/:contact_id/labels/", h.Contact.ListLabels) + contacts.POST("/:contact_id/labels", h.Contact.UpdateLabels) + contacts.POST("/:contact_id/labels/", h.Contact.UpdateLabels) contacts.DELETE("/:contact_id/custom_attributes", h.Contact.DeleteCustomAttributes) contacts.POST("/:contact_id/destroy_custom_attributes", h.Contact.DestroyCustomAttributes) @@ -1002,6 +1007,7 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) { // Contact inboxes (ref: Chatwoot nested contact_inboxes under contacts) contacts.GET("/:contact_id/contact_inboxes", h.Contact.ListContactInboxes) contacts.POST("/:contact_id/contact_inboxes", h.Contact.CreateContactInbox) + contacts.POST("/:contact_id/contact_inboxes/", h.Contact.CreateContactInbox) contacts.DELETE("/:contact_id/contact_inboxes/:inbox_id", h.Contact.DeleteContactInbox) } @@ -1022,6 +1028,8 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) { companies.PUT("/:company_id", h.Company.Update) companies.PATCH("/:company_id", h.Company.Update) companies.DELETE("/:company_id", h.Company.Delete) + companies.POST("/:company_id/destroy_custom_attributes", h.Company.DestroyCustomAttributes) + companies.DELETE("/:company_id/avatar", h.Company.DeleteAvatar) // Nested contacts under a company companies.GET("/:company_id/contacts", h.Company.ListContacts) companies.GET("/:company_id/contacts/search", h.Company.SearchContacts) diff --git a/internal/service/company_service.go b/internal/service/company_service.go index 4f04a940..3e5c8603 100644 --- a/internal/service/company_service.go +++ b/internal/service/company_service.go @@ -337,6 +337,40 @@ func (s *CompanyService) AddContact(ctx context.Context, companyID, accountID, c return nil } +func (s *CompanyService) DestroyCustomAttributes(ctx context.Context, companyID, accountID uint, keys []string) (*model.Company, error) { + company, err := s.companyRepo.FindByIDAndAccount(ctx, companyID, accountID) + if err != nil { + return nil, err + } + attrs := map[string]any{} + if len(company.CustomAttributes) > 0 { + _ = json.Unmarshal(company.CustomAttributes, &attrs) + } + for _, key := range keys { + delete(attrs, key) + } + bytes, _ := json.Marshal(attrs) + company.CustomAttributes = datatypes.JSON(bytes) + if err := s.companyRepo.Update(ctx, company); err != nil { + return nil, err + } + s.indexCompany(ctx, company) + return company, nil +} + +func (s *CompanyService) DeleteAvatar(ctx context.Context, companyID, accountID uint) (*model.Company, error) { + company, err := s.companyRepo.FindByIDAndAccount(ctx, companyID, accountID) + if err != nil { + return nil, err + } + company.FaviconURL = "" + if err := s.companyRepo.Update(ctx, company); err != nil { + return nil, err + } + s.indexCompany(ctx, company) + return company, nil +} + func mergeJSON(current datatypes.JSON, incoming datatypes.JSON) datatypes.JSON { if len(incoming) == 0 { return current diff --git a/internal/service/contact_service.go b/internal/service/contact_service.go index b8dfdf15..be0bf701 100644 --- a/internal/service/contact_service.go +++ b/internal/service/contact_service.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "strconv" + "strings" "gorm.io/datatypes" "gorm.io/gorm" @@ -62,16 +63,16 @@ func (s *ContactService) DB() *gorm.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) +func (s *ContactService) ListByAccount(ctx context.Context, accountID uint, offset, limit int, sort string, labels ...[]string) ([]model.Contact, int64, error) { + return s.repo.FindByAccount(ctx, accountID, offset, limit, sort, labels...) } // Search searches contacts by name, email, phone, or identifier with optional sort. -func (s *ContactService) Search(ctx context.Context, accountID uint, query string, offset, limit int, sort string, searchMode search.SearchMode) ([]model.Contact, int64, error) { +func (s *ContactService) Search(ctx context.Context, accountID uint, query string, offset, limit int, sort string, searchMode search.SearchMode, labels ...[]string) ([]model.Contact, int64, error) { if query == "" { - return s.repo.FindByAccount(ctx, accountID, offset, limit, sort) + return s.repo.FindByAccount(ctx, accountID, offset, limit, sort, labels...) } - return s.repo.Search(ctx, accountID, query, offset, limit, sort, searchMode) + return s.repo.Search(ctx, accountID, query, offset, limit, sort, searchMode, labels...) } // GetByID retrieves a single contact. @@ -474,6 +475,84 @@ func (s *ContactService) DestroyCustomAttributes(ctx context.Context, accountID, return contact, nil } +func (s *ContactService) DeleteAvatar(ctx context.Context, accountID, contactID uint) (*model.Contact, error) { + contact, err := s.repo.FindByAccountAndID(ctx, accountID, contactID) + if err != nil { + return nil, errors.New("contact not found") + } + contact.AvatarURL = "" + if err := s.repo.Update(ctx, contact); err != nil { + return nil, err + } + s.indexContact(ctx, contact) + return contact, nil +} + +func (s *ContactService) GetLabels(ctx context.Context, accountID, contactID uint) ([]string, error) { + if _, err := s.repo.FindByAccountAndID(ctx, accountID, contactID); err != nil { + return nil, errors.New("contact not found") + } + var rows []struct{ Name string } + err := s.DB().WithContext(ctx).Table("contact_labels"). + Select("tags.name"). + Joins("JOIN tags ON tags.id = contact_labels.tag_id"). + Where("contact_labels.account_id = ? AND contact_labels.contact_id = ? AND tags.deleted_at IS NULL", accountID, contactID). + Order("contact_labels.created_at ASC, tags.name ASC"). + Scan(&rows).Error + if err != nil { + return nil, err + } + labels := make([]string, 0, len(rows)) + for _, row := range rows { + labels = append(labels, row.Name) + } + return labels, nil +} + +func (s *ContactService) UpdateLabels(ctx context.Context, accountID, contactID uint, labels []string) ([]string, error) { + if _, err := s.repo.FindByAccountAndID(ctx, accountID, contactID); err != nil { + return nil, errors.New("contact not found") + } + normalized := normalizeContactServiceLabels(labels) + err := s.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Where("account_id = ? AND contact_id = ?", accountID, contactID).Delete(&model.ContactLabel{}).Error; err != nil { + return err + } + for _, label := range normalized { + tag := model.Tag{AccountID: accountID, Name: label} + if err := tx.Where("account_id = ? AND name = ?", accountID, label).FirstOrCreate(&tag).Error; err != nil { + return err + } + contactLabel := model.ContactLabel{AccountID: accountID, ContactID: contactID, TagID: tag.ID} + if err := tx.Create(&contactLabel).Error; err != nil { + return err + } + } + return nil + }) + if err != nil { + return nil, err + } + return normalized, nil +} + +func normalizeContactServiceLabels(labels []string) []string { + seen := map[string]struct{}{} + result := make([]string, 0, len(labels)) + for _, label := range labels { + label = strings.TrimSpace(label) + if label == "" { + continue + } + if _, ok := seen[label]; ok { + continue + } + seen[label] = struct{}{} + result = append(result, label) + } + return result +} + func mergeContactJSON(current datatypes.JSON, incoming datatypes.JSON) datatypes.JSON { if len(incoming) == 0 { return current diff --git a/internal/service/service_test_helper.go b/internal/service/service_test_helper.go index c6c16b09..c77b1275 100644 --- a/internal/service/service_test_helper.go +++ b/internal/service/service_test_helper.go @@ -66,6 +66,8 @@ func setupServiceTestDB(t *testing.T) *gorm.DB { &model.PortalMember{}, &model.Company{}, &model.CompanyNote{}, + &model.Tag{}, + &model.ContactLabel{}, ); err != nil { t.Fatalf("failed to auto-migrate models: %v", err) } diff --git a/migrations/000019_add_contact_labels.down.sql b/migrations/000019_add_contact_labels.down.sql new file mode 100644 index 00000000..fe09cadf --- /dev/null +++ b/migrations/000019_add_contact_labels.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS contact_labels; diff --git a/migrations/000019_add_contact_labels.up.sql b/migrations/000019_add_contact_labels.up.sql new file mode 100644 index 00000000..df8760e2 --- /dev/null +++ b/migrations/000019_add_contact_labels.up.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS contact_labels ( + contact_id INTEGER NOT NULL, + tag_id INTEGER NOT NULL, + account_id INTEGER NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (contact_id, tag_id) +); + +CREATE INDEX IF NOT EXISTS idx_contact_labels_contact_id ON contact_labels(contact_id); +CREATE INDEX IF NOT EXISTS idx_contact_labels_tag_id ON contact_labels(tag_id); +CREATE INDEX IF NOT EXISTS idx_contact_labels_account_id ON contact_labels(account_id);