feat(contacts): initiate voice calls
This commit is contained in:
@@ -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
|
||||
// ===========================
|
||||
|
||||
Reference in New Issue
Block a user