feat(contacts): initiate voice calls

This commit is contained in:
2026-06-06 05:44:27 +08:00
parent 2551353e31
commit 689c79bb9c
9 changed files with 467 additions and 29 deletions
@@ -1,11 +1,13 @@
package v1
import (
"errors"
"io"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/repository"
@@ -288,6 +290,48 @@ func (h *ContactHandler) Delete(c *gin.Context) {
c.Status(http.StatusOK)
}
func (h *ContactHandler) InitiateCall(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
}
userID := getUserID(c)
if userID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
return
}
var body struct {
InboxID uint `json:"inbox_id" binding:"required"`
ConversationID *uint `json:"conversation_id"`
}
if bindErr := c.ShouldBindJSON(&body); bindErr != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": bindErr.Error()})
return
}
result, svcErr := h.svc.InitiateCall(c.Request.Context(), accountID, contactID, service.InitiateContactCallRequest{
InboxID: body.InboxID,
ConversationID: body.ConversationID,
UserID: userID,
})
if svcErr != nil {
if errors.Is(svcErr, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "resource not found"})
return
}
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": svcErr.Error()})
return
}
c.JSON(http.StatusOK, result)
}
func (h *ContactHandler) DeleteAvatar(c *gin.Context) {
accountID := parseAccountIDParam(c)
if accountID == 0 {
@@ -57,6 +57,7 @@ func (s *ContactHandlerCRUDTestSuite) SetupSuite() {
&model.Notification{},
&model.Conversation{},
&model.Message{},
&model.Call{},
&model.Attachment{},
&model.ContactInbox{},
&model.InboxMember{},
@@ -96,6 +97,7 @@ func (s *ContactHandlerCRUDTestSuite) SetupSuite() {
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.POST("/api/v1/accounts/:id/contacts/:contact_id/call", s.handler.InitiateCall)
s.router.GET("/api/v1/accounts/:id/contacts/:contact_id/conversations", s.handler.ListConversations)
s.router.GET("/api/v1/accounts/:id/contacts/:contact_id/attachments", s.handler.ListAttachments)
s.router.GET("/api/v1/accounts/:id/contacts/:contact_id/labels", s.handler.ListLabels)
@@ -139,6 +141,7 @@ func (s *ContactHandlerCRUDTestSuite) SetupTest() {
s.db.Exec("DELETE FROM data_imports")
s.db.Exec("DELETE FROM notifications")
s.db.Exec("DELETE FROM attachments")
s.db.Exec("DELETE FROM calls")
s.db.Exec("DELETE FROM messages")
s.db.Exec("DELETE FROM notes")
s.db.Exec("DELETE FROM contact_inboxes")
@@ -976,6 +979,161 @@ func (s *ContactHandlerCRUDTestSuite) TestListContactInboxes_EmptyResult() {
s.Equal(float64(0), meta["count"])
}
func (s *ContactHandlerCRUDTestSuite) TestInitiateCall_CreatesConversationCallAndVoiceMessage() {
inbox := s.createVoiceInbox(true, true)
w := httptest.NewRecorder()
body := []byte(fmt.Sprintf(`{"inbox_id":%d}`, inbox.ID))
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/call", s.account.ID, s.contact.ID), bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
s.Require().Equal(http.StatusOK, w.Code, w.Body.String())
var resp map[string]interface{}
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
s.Equal(float64(inbox.ID), resp["inbox_id"])
s.NotEmpty(resp["call_sid"])
s.NotEmpty(resp["conference_sid"])
var conversation model.Conversation
s.Require().NoError(s.db.Where("account_id = ? AND display_id = ?", s.account.ID, uint(resp["conversation_id"].(float64))).First(&conversation).Error)
s.Equal(inbox.ID, conversation.InboxID)
s.Equal(s.contact.ID, conversation.ContactID)
s.Equal("open", conversation.Status)
s.NotNil(conversation.ContactInboxID)
var contactInbox model.ContactInbox
s.Require().NoError(s.db.Where("contact_id = ? AND inbox_id = ?", s.contact.ID, inbox.ID).First(&contactInbox).Error)
s.Equal(s.contact.PhoneNumber, contactInbox.SourceID)
var call model.Call
s.Require().NoError(s.db.Where("conversation_id = ?", conversation.ID).First(&call).Error)
s.Equal("twilio", call.Provider)
s.Equal("outgoing", call.Direction)
s.Equal("outbound", call.CallDirection)
s.Equal("ringing", call.Status)
s.NotNil(call.MessageID)
s.Equal(resp["call_sid"], call.ProviderCallID)
s.Equal(resp["conference_sid"], call.ConferenceSID)
var message model.Message
s.Require().NoError(s.db.First(&message, *call.MessageID).Error)
s.Equal("voice_call", message.ContentType)
s.Equal("outgoing", message.MessageType)
s.Equal(call.ID, uint(jsonNumberAt(s.T(), message.ContentAttributes, "data", "call_id")))
s.Equal(call.ProviderCallID, jsonStringAt(s.T(), message.ContentAttributes, "data", "call_sid"))
s.Equal("twilio", jsonStringAt(s.T(), message.ContentAttributes, "data", "call_source"))
}
func (s *ContactHandlerCRUDTestSuite) TestInitiateCall_ReusesOnlyMatchingOpenConversation() {
inbox := s.createVoiceInbox(true, true)
contactInbox := &model.ContactInbox{ContactID: s.contact.ID, InboxID: inbox.ID, SourceID: s.contact.PhoneNumber}
s.Require().NoError(s.db.Create(contactInbox).Error)
openConversation := &model.Conversation{AccountID: s.account.ID, InboxID: inbox.ID, ContactID: s.contact.ID, ContactInboxID: &contactInbox.ID, Status: "open", Priority: "low", ChannelType: inbox.ChannelType, Channel: inbox.ChannelType}
resolvedConversation := &model.Conversation{AccountID: s.account.ID, InboxID: inbox.ID, ContactID: s.contact.ID, ContactInboxID: &contactInbox.ID, Status: "resolved", Priority: "low", ChannelType: inbox.ChannelType, Channel: inbox.ChannelType}
s.Require().NoError(repository.NewConversationRepo(s.db).Create(s.T().Context(), openConversation))
s.Require().NoError(repository.NewConversationRepo(s.db).Create(s.T().Context(), resolvedConversation))
body := []byte(fmt.Sprintf(`{"inbox_id":%d,"conversation_id":%d}`, inbox.ID, *openConversation.DisplayID))
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/call", s.account.ID, s.contact.ID), bytes.NewReader(body))
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.Equal(float64(*openConversation.DisplayID), resp["conversation_id"])
body = []byte(fmt.Sprintf(`{"inbox_id":%d,"conversation_id":%d}`, inbox.ID, *resolvedConversation.DisplayID))
w = httptest.NewRecorder()
req, _ = http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/call", s.account.ID, s.contact.ID), bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
s.Equal(http.StatusOK, w.Code)
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
s.NotEqual(float64(*resolvedConversation.DisplayID), resp["conversation_id"])
}
func (s *ContactHandlerCRUDTestSuite) TestInitiateCall_RejectsInvalidVoiceInputs() {
inbox := s.createVoiceInbox(false, true)
body := []byte(fmt.Sprintf(`{"inbox_id":%d}`, inbox.ID))
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/call", s.account.ID, s.contact.ID), bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
s.Equal(http.StatusNotFound, w.Code)
assignedVoiceInbox := s.createVoiceInbox(true, true)
s.Require().NoError(s.db.Model(s.contact).Update("phone_number", "").Error)
body = []byte(fmt.Sprintf(`{"inbox_id":%d}`, assignedVoiceInbox.ID))
w = httptest.NewRecorder()
req, _ = http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/call", s.account.ID, s.contact.ID), bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
s.Equal(http.StatusUnprocessableEntity, w.Code)
}
func (s *ContactHandlerCRUDTestSuite) TestInitiateCall_RequiresAssignedInbox() {
inbox := s.createVoiceInbox(true, false)
body := []byte(fmt.Sprintf(`{"inbox_id":%d}`, inbox.ID))
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/call", s.account.ID, s.contact.ID), bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
s.Equal(http.StatusNotFound, w.Code)
}
func (s *ContactHandlerCRUDTestSuite) createVoiceInbox(voiceEnabled bool, assigned bool) *model.Inbox {
inbox := &model.Inbox{
AccountID: s.account.ID,
Name: fmt.Sprintf("Voice Inbox %d", time.Now().UnixNano()),
ChannelType: string(model.InboxChannelTypeTwilioSMS),
ChannelID: uint(time.Now().UnixNano()),
ChannelConfig: fmt.Sprintf(`{"voice_enabled":%t}`, voiceEnabled),
}
s.Require().NoError(s.db.Create(inbox).Error)
if assigned {
s.Require().NoError(s.db.Create(&model.InboxMember{InboxID: inbox.ID, UserID: s.user.ID, Role: "agent"}).Error)
}
return inbox
}
func jsonStringAt(t *testing.T, raw []byte, path ...string) string {
t.Helper()
value := jsonValueAt(t, raw, path...)
text, ok := value.(string)
if !ok {
t.Fatalf("expected string at %v, got %T", path, value)
}
return text
}
func jsonNumberAt(t *testing.T, raw []byte, path ...string) float64 {
t.Helper()
value := jsonValueAt(t, raw, path...)
number, ok := value.(float64)
if !ok {
t.Fatalf("expected number at %v, got %T", path, value)
}
return number
}
func jsonValueAt(t *testing.T, raw []byte, path ...string) any {
t.Helper()
var value any
if err := json.Unmarshal(raw, &value); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
for _, key := range path {
object, ok := value.(map[string]any)
if !ok {
t.Fatalf("expected object before %s, got %T", key, value)
}
value = object[key]
}
return value
}
// ===========================
// ListConversations
// ===========================