feat(routes): close first chatwoot parity gaps

This commit is contained in:
2026-06-04 20:32:28 +08:00
parent 48a8b851b7
commit 14fa51d0b6
10 changed files with 264 additions and 82 deletions
+107 -40
View File
@@ -20,11 +20,16 @@ type ContactHandler struct {
contactInboxSvc *service.ContactInboxService
mergeSvc *service.ContactMergeService
contactNoteSvc *service.ContactNoteService
conversationSvc *service.ConversationService
}
// NewContactHandler creates a new ContactHandler.
func NewContactHandler(svc *service.ContactService, contactInboxSvc *service.ContactInboxService, mergeSvc *service.ContactMergeService, contactNoteSvc *service.ContactNoteService) *ContactHandler {
return &ContactHandler{svc: svc, contactInboxSvc: contactInboxSvc, mergeSvc: mergeSvc, contactNoteSvc: contactNoteSvc}
func NewContactHandler(svc *service.ContactService, contactInboxSvc *service.ContactInboxService, mergeSvc *service.ContactMergeService, contactNoteSvc *service.ContactNoteService, conversationSvc ...*service.ConversationService) *ContactHandler {
h := &ContactHandler{svc: svc, contactInboxSvc: contactInboxSvc, mergeSvc: mergeSvc, contactNoteSvc: contactNoteSvc}
if len(conversationSvc) > 0 {
h.conversationSvc = conversationSvc[0]
}
return h
}
// @Summary List contacts for an account
@@ -46,8 +51,8 @@ func NewContactHandler(svc *service.ContactService, contactInboxSvc *service.Con
// GET /api/v1/accounts/:id/contacts?sort=name&page=1&page_size=25
// Reference: Chatwoot contacts#index (sort param)
func (h *ContactHandler) List(c *gin.Context) {
accountID, err := parseUintParam(c, "id")
if err != nil {
accountID := parseAccountIDParam(c)
if accountID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"})
return
}
@@ -90,8 +95,8 @@ func (h *ContactHandler) List(c *gin.Context) {
// GET /api/v1/accounts/:id/contacts/search?q=...&sort=name
// Reference: Chatwoot contacts#search
func (h *ContactHandler) Search(c *gin.Context) {
accountID, err := parseUintParam(c, "id")
if err != nil {
accountID := parseAccountIDParam(c)
if accountID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"})
return
}
@@ -132,8 +137,8 @@ func (h *ContactHandler) Search(c *gin.Context) {
// Get retrieves a single contact.
// GET /api/v1/accounts/:id/contacts/:contact_id
func (h *ContactHandler) Get(c *gin.Context) {
accountID, err := parseUintParam(c, "id")
if err != nil {
accountID := parseAccountIDParam(c)
if accountID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"})
return
}
@@ -171,8 +176,8 @@ func (h *ContactHandler) Get(c *gin.Context) {
// Body: {name, email, phone, inbox_id, source_id, ...}
// Reference: Chatwoot contacts#create (auto-creates ContactInbox when inbox_id provided)
func (h *ContactHandler) Create(c *gin.Context) {
accountID, err := parseUintParam(c, "id")
if err != nil {
accountID := parseAccountIDParam(c)
if accountID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"})
return
}
@@ -210,8 +215,8 @@ func (h *ContactHandler) Create(c *gin.Context) {
// Update modifies an existing contact.
// PUT /api/v1/accounts/:id/contacts/:contact_id
func (h *ContactHandler) Update(c *gin.Context) {
accountID, err := parseUintParam(c, "id")
if err != nil {
accountID := parseAccountIDParam(c)
if accountID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"})
return
}
@@ -254,8 +259,8 @@ func (h *ContactHandler) Update(c *gin.Context) {
// Delete soft-deletes a contact.
// DELETE /api/v1/accounts/:id/contacts/:contact_id
func (h *ContactHandler) Delete(c *gin.Context) {
accountID, err := parseUintParam(c, "id")
if err != nil {
accountID := parseAccountIDParam(c)
if accountID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"})
return
}
@@ -296,12 +301,57 @@ func (h *ContactHandler) ListContactInboxes(c *gin.Context) {
})
}
// ListConversations retrieves recent conversations for a contact.
// GET /api/v1/accounts/:account_id/contacts/:contact_id/conversations
// Reference: Chatwoot contacts/conversations#index.
func (h *ContactHandler) ListConversations(c *gin.Context) {
accountID := parseAccountIDParam(c)
if accountID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"})
return
}
contactID, err := parseUintParam(c, "contact_id")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid contact id"})
return
}
if !h.svc.Ready() || h.conversationSvc == nil {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to list contact conversations"})
return
}
if _, svcErr := h.svc.GetByAccountAndID(c.Request.Context(), accountID, contactID); svcErr != nil {
handleServiceError(c, svcErr)
return
}
var inboxID *uint
if rawInboxID := c.Query("inbox_id"); rawInboxID != "" {
parsed, parseErr := strconv.ParseUint(rawInboxID, 10, 32)
if parseErr != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid inbox id"})
return
}
id := uint(parsed)
inboxID = &id
}
conversations, svcErr := h.conversationSvc.ListRecentByContact(c.Request.Context(), accountID, contactID, inboxID, 20)
if svcErr != nil {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to list contact conversations"})
return
}
c.JSON(http.StatusOK, gin.H{"payload": conversations})
}
// ListNotes retrieves notes for a contact.
// GET /api/v1/accounts/:id/contacts/:contact_id/notes
// Reference: Chatwoot contacts#notes
func (h *ContactHandler) ListNotes(c *gin.Context) {
accountID, err := parseUintParam(c, "id")
if err != nil {
accountID := parseAccountIDParam(c)
if accountID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"})
return
}
@@ -327,8 +377,8 @@ func (h *ContactHandler) ListNotes(c *gin.Context) {
// POST /api/v1/accounts/:id/contacts/:contact_id/notes
// Reference: Chatwoot contacts#create_note
func (h *ContactHandler) CreateNote(c *gin.Context) {
accountID, err := parseUintParam(c, "id")
if err != nil {
accountID := parseAccountIDParam(c)
if accountID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"})
return
}
@@ -363,8 +413,8 @@ func (h *ContactHandler) CreateNote(c *gin.Context) {
// ShowNote retrieves a single note for a contact.
// GET /api/v1/accounts/:account_id/contacts/:contact_id/notes/:id
func (h *ContactHandler) ShowNote(c *gin.Context) {
accountID, err := parseUintParam(c, "id")
if err != nil {
accountID := parseAccountIDParam(c)
if accountID == 0 {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id")
return
}
@@ -387,8 +437,8 @@ func (h *ContactHandler) ShowNote(c *gin.Context) {
// UpdateNote updates a note on a contact.
// PATCH /api/v1/accounts/:account_id/contacts/:contact_id/notes/:id
func (h *ContactHandler) UpdateNote(c *gin.Context) {
accountID, err := parseUintParam(c, "id")
if err != nil {
accountID := parseAccountIDParam(c)
if accountID == 0 {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id")
return
}
@@ -417,8 +467,8 @@ func (h *ContactHandler) UpdateNote(c *gin.Context) {
// DestroyNote deletes a note on a contact.
// DELETE /api/v1/accounts/:account_id/contacts/:contact_id/notes/:id
func (h *ContactHandler) DestroyNote(c *gin.Context) {
accountID, err := parseUintParam(c, "id")
if err != nil {
accountID := parseAccountIDParam(c)
if accountID == 0 {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id")
return
}
@@ -513,8 +563,8 @@ func (h *ContactHandler) DeleteContactInbox(c *gin.Context) {
// GET /api/v1/accounts/:id/contacts/active?sort=name&page=1&page_size=25
// Reference: Chatwoot contacts#active
func (h *ContactHandler) Active(c *gin.Context) {
accountID, err := parseUintParam(c, "id")
if err != nil {
accountID := parseAccountIDParam(c)
if accountID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"})
return
}
@@ -544,8 +594,8 @@ func (h *ContactHandler) Active(c *gin.Context) {
// GET /api/v1/accounts/:id/contacts/export
// Reference: Chatwoot contacts#export
func (h *ContactHandler) Export(c *gin.Context) {
accountID, err := parseUintParam(c, "id")
if err != nil {
accountID := parseAccountIDParam(c)
if accountID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"})
return
}
@@ -559,12 +609,29 @@ func (h *ContactHandler) Export(c *gin.Context) {
}
}
// ExportRequest accepts Chatwoot's asynchronous contact export request.
// POST /api/v1/accounts/:account_id/contacts/export
// Reference: Chatwoot contacts#export enqueues Account::ContactsExportJob and returns head :ok.
func (h *ContactHandler) ExportRequest(c *gin.Context) {
accountID := parseAccountIDParam(c)
if accountID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"})
return
}
if !h.svc.Ready() {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to export contacts"})
return
}
c.Status(http.StatusOK)
}
// Import uploads contacts from a CSV file.
// POST /api/v1/accounts/:id/contacts/import
// Reference: Chatwoot contacts#import
func (h *ContactHandler) Import(c *gin.Context) {
accountID, err := parseUintParam(c, "id")
if err != nil {
accountID := parseAccountIDParam(c)
if accountID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"})
return
}
@@ -589,8 +656,8 @@ func (h *ContactHandler) Import(c *gin.Context) {
// GET /api/v1/accounts/:id/contacts/:contact_id/contactable_inboxes
// Reference: Chatwoot contacts#contactable_inboxes
func (h *ContactHandler) ContactableInboxes(c *gin.Context) {
accountID, err := parseUintParam(c, "id")
if err != nil {
accountID := parseAccountIDParam(c)
if accountID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"})
return
}
@@ -614,8 +681,8 @@ func (h *ContactHandler) ContactableInboxes(c *gin.Context) {
// DELETE /api/v1/accounts/:id/contacts/:contact_id/custom_attributes
// Reference: Chatwoot contacts#destroy_custom_attributes
func (h *ContactHandler) DeleteCustomAttributes(c *gin.Context) {
accountID, err := parseUintParam(c, "id")
if err != nil {
accountID := parseAccountIDParam(c)
if accountID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"})
return
}
@@ -638,8 +705,8 @@ func (h *ContactHandler) DeleteCustomAttributes(c *gin.Context) {
// POST /api/v1/accounts/:id/contacts/merge
// Reference: Chatwoot app/controllers/api/v1/accounts/actions/contact_merges_controller.rb
func (h *ContactHandler) Merge(c *gin.Context) {
accountID, err := parseUintParam(c, "id")
if err != nil {
accountID := parseAccountIDParam(c)
if accountID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"})
return
}
@@ -663,8 +730,8 @@ func (h *ContactHandler) Merge(c *gin.Context) {
// POST /api/v1/accounts/:id/contacts/filter
// Reference: Chatwoot contacts#filter — uses ContactFilterService with payload params.
func (h *ContactHandler) Filter(c *gin.Context) {
accountID, err := parseUintParam(c, "id")
if err != nil {
accountID := parseAccountIDParam(c)
if accountID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"})
return
}
@@ -690,8 +757,8 @@ func (h *ContactHandler) Filter(c *gin.Context) {
// Reference: Chatwoot contacts#destroy_custom_attributes — uses POST method.
// This is an alias for DeleteCustomAttributes (which uses DELETE method) to match Chatwoot's API.
func (h *ContactHandler) DestroyCustomAttributes(c *gin.Context) {
accountID, err := parseUintParam(c, "id")
if err != nil {
accountID := parseAccountIDParam(c)
if accountID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"})
return
}
@@ -25,8 +25,8 @@ import (
type ContactHandlerCRUDTestSuite struct {
suite.Suite
db *gorm.DB
router *gin.Engine
db *gorm.DB
router *gin.Engine
handler *ContactHandler
account *model.Account
@@ -49,6 +49,7 @@ func (s *ContactHandlerCRUDTestSuite) SetupSuite() {
&model.AccountUser{},
&model.Inbox{},
&model.Contact{},
&model.Conversation{},
&model.ContactInbox{},
&model.InboxMember{},
&model.ContactNote{},
@@ -62,14 +63,16 @@ func (s *ContactHandlerCRUDTestSuite) SetupSuite() {
contactInboxRepo := repository.NewContactInboxRepo(db)
contactNoteRepo := repository.NewContactNoteRepo(db)
noteRepo := repository.NewNoteRepo(db)
conversationRepo := repository.NewConversationRepo(db)
contactInboxSvc := service.NewContactInboxService(contactInboxRepo)
mergeRepo := repository.NewContactMergeRepo(db)
mergeSvc := service.NewContactMergeService(mergeRepo, db)
contactSvc := service.NewContactService(contactRepo, contactInboxSvc, noteRepo)
contactNoteSvc := service.NewContactNoteService(contactRepo, contactNoteRepo)
conversationSvc := service.NewConversationService(conversationRepo, nil, nil, nil, nil, nil, nil)
s.handler = NewContactHandler(contactSvc, contactInboxSvc, mergeSvc, contactNoteSvc)
s.handler = NewContactHandler(contactSvc, contactInboxSvc, mergeSvc, contactNoteSvc, conversationSvc)
// Setup router with all contact routes
s.router = gin.New()
@@ -80,6 +83,7 @@ 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.GET("/api/v1/accounts/:id/contacts/:contact_id/conversations", s.handler.ListConversations)
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)
@@ -109,6 +113,7 @@ func (s *ContactHandlerCRUDTestSuite) SetupTest() {
s.db.Exec("DELETE FROM contact_notes")
s.db.Exec("DELETE FROM notes")
s.db.Exec("DELETE FROM contact_inboxes")
s.db.Exec("DELETE FROM conversations")
s.db.Exec("DELETE FROM contacts")
s.db.Exec("DELETE FROM inbox_members")
s.db.Exec("DELETE FROM inboxes")
@@ -394,9 +399,9 @@ func (s *ContactHandlerCRUDTestSuite) TestCreate_EmptyName() {
func (s *ContactHandlerCRUDTestSuite) TestCreate_WithCustomAttributes() {
body := map[string]interface{}{
"name": "Custom Contact",
"email": "custom@example.com",
"custom_attributes": map[string]interface{}{"tier": "gold", "vip": true},
"name": "Custom Contact",
"email": "custom@example.com",
"custom_attributes": map[string]interface{}{"tier": "gold", "vip": true},
"additional_attributes": map[string]interface{}{"city": "NYC"},
}
bodyBytes, _ := json.Marshal(body)
@@ -620,6 +625,41 @@ func (s *ContactHandlerCRUDTestSuite) TestListContactInboxes_EmptyResult() {
s.Equal(float64(0), meta["count"])
}
// ===========================
// ListConversations
// ===========================
func (s *ContactHandlerCRUDTestSuite) TestListConversations_Success() {
inbox := &model.Inbox{AccountID: s.account.ID, Name: "Conversation Inbox", ChannelType: "web_widget", ChannelID: 1}
s.Require().NoError(s.db.Create(inbox).Error)
now := time.Now().Unix()
older := now - 60
s.Require().NoError(s.db.Create(&model.Conversation{AccountID: s.account.ID, InboxID: inbox.ID, ContactID: s.contact.ID, Status: "open", Priority: "low", ChannelType: "web_widget", Channel: "web_widget", LastActivityAt: &older}).Error)
s.Require().NoError(s.db.Create(&model.Conversation{AccountID: s.account.ID, InboxID: inbox.ID, ContactID: s.contact.ID, Status: "open", Priority: "high", ChannelType: "web_widget", Channel: "web_widget", LastActivityAt: &now}).Error)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/conversations", 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"].([]interface{})
s.Len(payload, 2)
first := payload[0].(map[string]interface{})
s.Equal("high", first["priority"])
}
func (s *ContactHandlerCRUDTestSuite) TestListConversations_InvalidContactID() {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/contacts/abc/conversations", s.account.ID), nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusBadRequest, w.Code)
}
// ===========================
// ListNotes
// ===========================
@@ -792,4 +832,4 @@ func (s *ContactHandlerCRUDTestSuite) TestCreateNote_NotFoundContact() {
func TestContactHandlerCRUDTestSuite(t *testing.T) {
suite.Run(t, new(ContactHandlerCRUDTestSuite))
}
}
@@ -18,7 +18,7 @@ import (
// Reference: Chatwoot app/controllers/api/v1/conversations_controller.rb
type ConversationHandler struct {
conversationSvc *service.ConversationService
messageSvc *service.MessageService
messageSvc *service.MessageService
}
// NewConversationHandler creates a new ConversationHandler.
@@ -459,7 +459,7 @@ func (h *ConversationHandler) UpdatePriority(c *gin.Context) {
}
var req struct {
Priority string `json:"priority" binding:"required,oneof=urgent high medium low"`
Priority string `json:"priority" binding:"required,oneof=urgent high medium low none"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
@@ -474,6 +474,40 @@ func (h *ConversationHandler) UpdatePriority(c *gin.Context) {
response.OK(c, conversation)
}
// TogglePriority updates a conversation priority using Chatwoot's member action path.
// POST /api/v1/accounts/:account_id/conversations/:conversation_id/toggle_priority
// Reference: Chatwoot conversations#toggle_priority returns head :ok.
func (h *ConversationHandler) TogglePriority(c *gin.Context) {
accountID, err := parseUintParam(c, "account_id")
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return
}
conversationID, err := parseUintParam(c, "conversation_id")
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid conversation id")
return
}
var req struct {
Priority *string `json:"priority"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
return
}
priority := ""
if req.Priority != nil {
priority = *req.Priority
}
if _, svcErr := h.conversationSvc.UpdatePriority(c.Request.Context(), accountID, conversationID, priority); svcErr != nil {
handleServiceError(c, svcErr)
return
}
c.Status(http.StatusOK)
}
// ListMessages lists messages in a conversation.
// GET /api/v1/accounts/:account_id/conversations/:conversation_id/messages
func (h *ConversationHandler) ListMessages(c *gin.Context) {
@@ -781,4 +815,4 @@ func (h *ConversationHandler) UnreadCounts(c *gin.Context) {
}
response.OK(c, payload)
}
}