feat(crm): complete contact label avatar gaps
This commit is contained in:
@@ -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: "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: "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/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/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/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: "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: "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: "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"},
|
{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: "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: "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: "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: "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: "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/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"},
|
{Method: "GET", Path: "/api/v1/accounts/:account_id/companies/:company_id/notes", Controller: "api/v1/accounts/companies/notes#index", Source: "routes.rb:194"},
|
||||||
|
|
||||||
|
|||||||
@@ -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/captain/scenarios/:scenario_id
|
||||||
DELETE /api/v1/accounts/:account_id/channels/facebook_channel/:fb_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
|
||||||
|
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/contacts/:contact_id
|
||||||
DELETE /api/v1/accounts/:account_id/companies/:company_id/notes/:note_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
|
||||||
|
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/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
|
||||||
DELETE /api/v1/accounts/:account_id/contacts/:contact_id/custom_attributes/:attribute_name
|
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/contact_inboxes
|
||||||
GET /api/v1/accounts/:account_id/contacts/:contact_id/contactable_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/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
|
||||||
GET /api/v1/accounts/:account_id/contacts/:contact_id/notes/:note_id
|
GET /api/v1/accounts/:account_id/contacts/:contact_id/notes/:note_id
|
||||||
GET /api/v1/accounts/:account_id/contacts/active
|
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/
|
||||||
POST /api/v1/accounts/:account_id/companies/:company_id/contacts
|
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/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/companies/:company_id/notes
|
||||||
POST /api/v1/accounts/:account_id/contact_merge
|
POST /api/v1/accounts/:account_id/contact_merge
|
||||||
POST /api/v1/accounts/:account_id/contacts/
|
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/contact_inboxes/
|
||||||
POST /api/v1/accounts/:account_id/contacts/:contact_id/custom_attributes/
|
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/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/:contact_id/notes
|
||||||
POST /api/v1/accounts/:account_id/contacts/export
|
POST /api/v1/accounts/:account_id/contacts/export
|
||||||
POST /api/v1/accounts/:account_id/contacts/filter
|
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
|
||||||
PUT /public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/messages/:message_id
|
PUT /public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/messages/:message_id
|
||||||
PUT /widget/direct_uploads/:upload_uuid
|
PUT /widget/direct_uploads/:upload_uuid
|
||||||
TOTAL: 809
|
TOTAL: 817
|
||||||
|
|||||||
@@ -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`.
|
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
|
## 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/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/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` | `/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/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` | `/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` | `/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/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 |
|
| 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/` | `/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` | `/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` | `/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/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/: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/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/` | `/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` | `/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/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/: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/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/` | `/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 |
|
| 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/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/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/` | `/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/` | `/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/: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/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 |
|
| POST | `/api/v1/accounts/:account_id/contacts/filter` | `/api/v1/accounts/:account_id/contacts/filter` | `api/v1/accounts/contacts#filter` | `routes.rb:202` | exact |
|
||||||
|
|||||||
@@ -175,6 +175,55 @@ func (h *CompanyHandler) Delete(c *gin.Context) {
|
|||||||
c.Status(http.StatusOK)
|
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.
|
// ListContacts retrieves contacts associated with a company.
|
||||||
// GET /api/v1/accounts/:id/companies/:company_id/contacts?page=1&per_page=25
|
// GET /api/v1/accounts/:id/companies/:company_id/contacts?page=1&per_page=25
|
||||||
func (h *CompanyHandler) ListContacts(c *gin.Context) {
|
func (h *CompanyHandler) ListContacts(c *gin.Context) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/suite"
|
"github.com/stretchr/testify/suite"
|
||||||
|
"gorm.io/datatypes"
|
||||||
"gorm.io/driver/sqlite"
|
"gorm.io/driver/sqlite"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/logger"
|
"gorm.io/gorm/logger"
|
||||||
@@ -103,6 +104,8 @@ func (s *CompanyHandlerTestSuite) SetupSuite() {
|
|||||||
companies.PUT("/:company_id", s.handler.Update)
|
companies.PUT("/:company_id", s.handler.Update)
|
||||||
companies.PATCH("/:company_id", s.handler.Update)
|
companies.PATCH("/:company_id", s.handler.Update)
|
||||||
companies.DELETE("/:company_id", s.handler.Delete)
|
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", s.handler.ListContacts)
|
||||||
companies.GET("/:company_id/contacts/search", s.handler.SearchContacts)
|
companies.GET("/:company_id/contacts/search", s.handler.SearchContacts)
|
||||||
companies.POST("/:company_id/contacts", s.handler.AddContact)
|
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)
|
"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 ==========
|
// ========== ListContacts ==========
|
||||||
|
|
||||||
func (s *CompanyHandlerTestSuite) TestListContacts_Success() {
|
func (s *CompanyHandlerTestSuite) TestListContacts_Success() {
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ func (h *ContactHandler) List(c *gin.Context) {
|
|||||||
offset := (page - 1) * perPage
|
offset := (page - 1) * perPage
|
||||||
sort := c.DefaultQuery("sort", "")
|
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 {
|
if svcErr != nil {
|
||||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to list contacts"})
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to list contacts"})
|
||||||
return
|
return
|
||||||
@@ -110,7 +110,7 @@ func (h *ContactHandler) Search(c *gin.Context) {
|
|||||||
sort := c.DefaultQuery("sort", "")
|
sort := c.DefaultQuery("sort", "")
|
||||||
searchMode := search.ParseSearchMode(c.DefaultQuery("search_mode", ""))
|
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 {
|
if svcErr != nil {
|
||||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to search contacts"})
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to search contacts"})
|
||||||
return
|
return
|
||||||
@@ -286,6 +286,70 @@ func (h *ContactHandler) Delete(c *gin.Context) {
|
|||||||
c.Status(http.StatusOK)
|
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.
|
// ListContactInboxes retrieves all contact_inboxes for a contact.
|
||||||
// GET /api/v1/accounts/:id/contacts/:contact_id/contact_inboxes
|
// GET /api/v1/accounts/:id/contacts/:contact_id/contact_inboxes
|
||||||
// Reference: Chatwoot contacts#contact_inboxes (nested resource)
|
// Reference: Chatwoot contacts#contact_inboxes (nested resource)
|
||||||
@@ -503,6 +567,12 @@ func includeContactInboxes(c *gin.Context) bool {
|
|||||||
return true
|
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.
|
// parseIntOrDefault parses an integer query parameter with a default value.
|
||||||
func parseIntOrDefault(c *gin.Context, key string, defaultVal int) int {
|
func parseIntOrDefault(c *gin.Context, key string, defaultVal int) int {
|
||||||
val := c.Query(key)
|
val := c.Query(key)
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ func (s *ContactHandlerCRUDTestSuite) SetupSuite() {
|
|||||||
&model.AccountUser{},
|
&model.AccountUser{},
|
||||||
&model.Inbox{},
|
&model.Inbox{},
|
||||||
&model.Contact{},
|
&model.Contact{},
|
||||||
|
&model.Tag{},
|
||||||
|
&model.ContactLabel{},
|
||||||
&model.Conversation{},
|
&model.Conversation{},
|
||||||
&model.ContactInbox{},
|
&model.ContactInbox{},
|
||||||
&model.InboxMember{},
|
&model.InboxMember{},
|
||||||
@@ -83,7 +85,10 @@ func (s *ContactHandlerCRUDTestSuite) SetupSuite() {
|
|||||||
s.router.POST("/api/v1/accounts/:id/contacts", s.handler.Create)
|
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.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", 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/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/contact_inboxes", s.handler.ListContactInboxes)
|
||||||
s.router.GET("/api/v1/accounts/:id/contacts/:contact_id/notes", s.handler.ListNotes)
|
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)
|
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.
|
// SetupTest re-creates core test data before each test so tests don't leak state.
|
||||||
func (s *ContactHandlerCRUDTestSuite) SetupTest() {
|
func (s *ContactHandlerCRUDTestSuite) SetupTest() {
|
||||||
s.db.Exec("DELETE FROM contact_notes")
|
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 notes")
|
||||||
s.db.Exec("DELETE FROM contact_inboxes")
|
s.db.Exec("DELETE FROM contact_inboxes")
|
||||||
s.db.Exec("DELETE FROM conversations")
|
s.db.Exec("DELETE FROM conversations")
|
||||||
@@ -532,6 +539,63 @@ func (s *ContactHandlerCRUDTestSuite) TestDelete_Success() {
|
|||||||
s.Error(err, "contact should be soft-deleted")
|
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() {
|
func (s *ContactHandlerCRUDTestSuite) TestDelete_InvalidAccountID() {
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
req, _ := http.NewRequest("DELETE",
|
req, _ := http.NewRequest("DELETE",
|
||||||
|
|||||||
@@ -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" }
|
||||||
@@ -39,29 +39,42 @@ func (r *ContactRepo) DB() *gorm.DB {
|
|||||||
|
|
||||||
// FindByAccount retrieves all contacts for an account with optional sort.
|
// 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"
|
// 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 contacts []model.Contact
|
||||||
var total int64
|
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 {
|
if err := countDB.Count(&total).Error; err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
orderClause := resolveContactSort(sort)
|
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).
|
Offset(offset).Limit(limit).Order(orderClause).
|
||||||
Find(&contacts).Error
|
Find(&contacts).Error
|
||||||
return contacts, total, err
|
return contacts, total, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search searches contacts by name, email, or phone number with optional sort.
|
// 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 contacts []model.Contact
|
||||||
var total int64
|
var total int64
|
||||||
|
|
||||||
condition := r.db.WithContext(ctx).Model(&model.Contact{}).
|
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 query != "" {
|
||||||
if searchMode == search.SearchModeTrigram {
|
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
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
orderClause := resolveContactSort(sort)
|
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
|
Find(&contacts).Error
|
||||||
return contacts, total, err
|
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.
|
// resolveContactSort maps a sort parameter to a SQL ORDER BY clause.
|
||||||
// Supported values: "name", "email", "created_at", "last_activity_at" (default).
|
// Supported values: "name", "email", "created_at", "last_activity_at" (default).
|
||||||
// Direction defaults to DESC for date fields, ASC for name/email.
|
// Direction defaults to DESC for date fields, ASC for name/email.
|
||||||
func resolveContactSort(sort string) string {
|
func resolveContactSort(sort string) string {
|
||||||
allowedSorts := map[string]string{
|
allowedSorts := map[string]string{
|
||||||
"name": "name ASC",
|
"name": "contacts.name ASC",
|
||||||
"email": "email ASC",
|
"email": "contacts.email ASC",
|
||||||
"created_at": "created_at DESC",
|
"created_at": "contacts.created_at DESC",
|
||||||
"last_activity_at": "last_activity_at DESC NULLS LAST, id DESC",
|
"last_activity_at": "contacts.last_activity_at DESC NULLS LAST, contacts.id DESC",
|
||||||
}
|
}
|
||||||
if clause, ok := allowedSorts[sort]; ok {
|
if clause, ok := allowedSorts[sort]; ok {
|
||||||
return clause
|
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.
|
// 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 contacts []model.Contact
|
||||||
var total int64
|
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
|
// Apply contact type filter
|
||||||
if params.ContactType != "" {
|
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
|
// Apply inbox filter via contact_inboxes join
|
||||||
if params.InboxID != nil {
|
if params.InboxID != nil {
|
||||||
q = q.Joins("JOIN contact_inboxes ON contact_inboxes.contact_id = contacts.id AND contact_inboxes.inbox_id = ?", *params.InboxID)
|
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 != "" {
|
if params.Labels != "" {
|
||||||
labelList := strings.Split(params.Labels, ",")
|
q = applyContactLabelFilter(q, accountID, strings.Split(params.Labels, ","))
|
||||||
for _, label := range labelList {
|
usesDistinct = true
|
||||||
q = q.Joins("JOIN conversations ON conversations.contact_id = contacts.id AND conversations.labels LIKE ?", "%"+label+"%")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply status filter (active = has recent activity, inactive = no recent activity)
|
// 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
|
// 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
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
orderClause := resolveContactSort(params.Sort)
|
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
|
Find(&contacts).Error
|
||||||
return contacts, total, err
|
return contacts, total, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ func defaultTestModels() []interface{} {
|
|||||||
&csat.CsatSurveyResponse{},
|
&csat.CsatSurveyResponse{},
|
||||||
&model.Tag{},
|
&model.Tag{},
|
||||||
&model.ConversationLabel{},
|
&model.ConversationLabel{},
|
||||||
|
&model.ContactLabel{},
|
||||||
&model.PlatformApp{},
|
&model.PlatformApp{},
|
||||||
&model.Permissible{},
|
&model.Permissible{},
|
||||||
&model.AccessToken{},
|
&model.AccessToken{},
|
||||||
|
|||||||
@@ -985,10 +985,15 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) {
|
|||||||
contacts.PUT("/:contact_id", h.Contact.Update)
|
contacts.PUT("/:contact_id", h.Contact.Update)
|
||||||
contacts.PATCH("/:contact_id", h.Contact.Update)
|
contacts.PATCH("/:contact_id", h.Contact.Update)
|
||||||
contacts.DELETE("/:contact_id", h.Contact.Delete)
|
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)
|
// M4 G3: Contact extension routes (active, export, import, contactable_inboxes, custom_attributes)
|
||||||
contacts.GET("/:contact_id/conversations", h.Contact.ListConversations)
|
contacts.GET("/:contact_id/conversations", h.Contact.ListConversations)
|
||||||
contacts.GET("/:contact_id/contactable_inboxes", h.Contact.ContactableInboxes)
|
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.DELETE("/:contact_id/custom_attributes", h.Contact.DeleteCustomAttributes)
|
||||||
contacts.POST("/:contact_id/destroy_custom_attributes", h.Contact.DestroyCustomAttributes)
|
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)
|
// Contact inboxes (ref: Chatwoot nested contact_inboxes under contacts)
|
||||||
contacts.GET("/:contact_id/contact_inboxes", h.Contact.ListContactInboxes)
|
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.POST("/:contact_id/contact_inboxes/", h.Contact.CreateContactInbox)
|
||||||
contacts.DELETE("/:contact_id/contact_inboxes/:inbox_id", h.Contact.DeleteContactInbox)
|
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.PUT("/:company_id", h.Company.Update)
|
||||||
companies.PATCH("/:company_id", h.Company.Update)
|
companies.PATCH("/:company_id", h.Company.Update)
|
||||||
companies.DELETE("/:company_id", h.Company.Delete)
|
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
|
// Nested contacts under a company
|
||||||
companies.GET("/:company_id/contacts", h.Company.ListContacts)
|
companies.GET("/:company_id/contacts", h.Company.ListContacts)
|
||||||
companies.GET("/:company_id/contacts/search", h.Company.SearchContacts)
|
companies.GET("/:company_id/contacts/search", h.Company.SearchContacts)
|
||||||
|
|||||||
@@ -337,6 +337,40 @@ func (s *CompanyService) AddContact(ctx context.Context, companyID, accountID, c
|
|||||||
return nil
|
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 {
|
func mergeJSON(current datatypes.JSON, incoming datatypes.JSON) datatypes.JSON {
|
||||||
if len(incoming) == 0 {
|
if len(incoming) == 0 {
|
||||||
return current
|
return current
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gorm.io/datatypes"
|
"gorm.io/datatypes"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -62,16 +63,16 @@ func (s *ContactService) DB() *gorm.DB {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ListByAccount retrieves all contacts for an account with optional sort.
|
// 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) {
|
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)
|
return s.repo.FindByAccount(ctx, accountID, offset, limit, sort, labels...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search searches contacts by name, email, phone, or identifier with optional sort.
|
// 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 == "" {
|
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.
|
// GetByID retrieves a single contact.
|
||||||
@@ -474,6 +475,84 @@ func (s *ContactService) DestroyCustomAttributes(ctx context.Context, accountID,
|
|||||||
return contact, nil
|
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 {
|
func mergeContactJSON(current datatypes.JSON, incoming datatypes.JSON) datatypes.JSON {
|
||||||
if len(incoming) == 0 {
|
if len(incoming) == 0 {
|
||||||
return current
|
return current
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ func setupServiceTestDB(t *testing.T) *gorm.DB {
|
|||||||
&model.PortalMember{},
|
&model.PortalMember{},
|
||||||
&model.Company{},
|
&model.Company{},
|
||||||
&model.CompanyNote{},
|
&model.CompanyNote{},
|
||||||
|
&model.Tag{},
|
||||||
|
&model.ContactLabel{},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
t.Fatalf("failed to auto-migrate models: %v", err)
|
t.Fatalf("failed to auto-migrate models: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS contact_labels;
|
||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user