feat(inboxes): enforce chatwoot inbox limits
This commit is contained in:
@@ -84,6 +84,13 @@ func (h *EmailChannelHandler) Create(c *gin.Context) {
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
if err := h.inboxSvc.EnsureCanCreateInbox(ctx, accountID); err != nil {
|
||||
if renderInboxLimitExceeded(c, err) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to create inbox"})
|
||||
return
|
||||
}
|
||||
|
||||
// Create the channel record first (without InboxID)
|
||||
ch := &channelmodel.ChannelEmail{
|
||||
@@ -136,6 +143,9 @@ func (h *EmailChannelHandler) Create(c *gin.Context) {
|
||||
if delErr := h.emailChannelSvc.Delete(ctx, ch.ID); delErr != nil {
|
||||
applogger.L().Warnf("Failed to rollback Email channel after inbox creation failure: %v", delErr)
|
||||
}
|
||||
if renderInboxLimitExceeded(c, err) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to create inbox"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -176,17 +176,20 @@ func (h *FacebookChannelHandler) CreateFacebookPage(c *gin.Context) {
|
||||
inbox, err := h.inboxSvc.CreateFacebookInbox(ctx, uint(accountID), inboxReq, h.fbRepo)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Failed to create Facebook inbox: %v", err)
|
||||
if renderInboxLimitExceeded(c, err) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to create Facebook inbox", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"inbox": gin.H{
|
||||
"id": inbox.ID,
|
||||
"name": inbox.Name,
|
||||
"channel_type": inbox.ChannelType,
|
||||
"channel_id": inbox.ChannelID,
|
||||
"enabled": inbox.Enabled,
|
||||
"id": inbox.ID,
|
||||
"name": inbox.Name,
|
||||
"channel_type": inbox.ChannelType,
|
||||
"channel_id": inbox.ChannelID,
|
||||
"enabled": inbox.Enabled,
|
||||
"enable_auto_assignment": inbox.EnableAutoAssignment,
|
||||
},
|
||||
})
|
||||
@@ -225,12 +228,12 @@ func (h *FacebookChannelHandler) GetFacebookChannel(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": ch.ID,
|
||||
"account_id": ch.AccountID,
|
||||
"inbox_id": ch.InboxID,
|
||||
"page_id": ch.PageID,
|
||||
"page_name": ch.PageName,
|
||||
"app_id": ch.AppID,
|
||||
"id": ch.ID,
|
||||
"account_id": ch.AccountID,
|
||||
"inbox_id": ch.InboxID,
|
||||
"page_id": ch.PageID,
|
||||
"page_name": ch.PageName,
|
||||
"app_id": ch.AppID,
|
||||
"reauthorization_required": ch.ReauthorizationRequired,
|
||||
})
|
||||
}
|
||||
@@ -257,12 +260,12 @@ func (h *FacebookChannelHandler) ListFacebookChannels(c *gin.Context) {
|
||||
result := make([]gin.H, 0, len(channels))
|
||||
for _, ch := range channels {
|
||||
result = append(result, gin.H{
|
||||
"id": ch.ID,
|
||||
"account_id": ch.AccountID,
|
||||
"inbox_id": ch.InboxID,
|
||||
"page_id": ch.PageID,
|
||||
"page_name": ch.PageName,
|
||||
"app_id": ch.AppID,
|
||||
"id": ch.ID,
|
||||
"account_id": ch.AccountID,
|
||||
"inbox_id": ch.InboxID,
|
||||
"page_id": ch.PageID,
|
||||
"page_name": ch.PageName,
|
||||
"app_id": ch.AppID,
|
||||
"reauthorization_required": ch.ReauthorizationRequired,
|
||||
})
|
||||
}
|
||||
@@ -405,8 +408,8 @@ func (h *FacebookChannelHandler) UpdateFacebookPage(c *gin.Context) {
|
||||
}
|
||||
|
||||
var req struct {
|
||||
PageName *string `json:"page_name"`
|
||||
ReauthorizationRequired *bool `json:"reauthorization_required"`
|
||||
PageName *string `json:"page_name"`
|
||||
ReauthorizationRequired *bool `json:"reauthorization_required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()})
|
||||
@@ -414,7 +417,7 @@ func (h *FacebookChannelHandler) UpdateFacebookPage(c *gin.Context) {
|
||||
}
|
||||
|
||||
updateReq := service.UpdateFacebookChannelRequest{
|
||||
PageName: req.PageName,
|
||||
PageName: req.PageName,
|
||||
ReauthorizationRequired: req.ReauthorizationRequired,
|
||||
}
|
||||
|
||||
@@ -426,12 +429,11 @@ func (h *FacebookChannelHandler) UpdateFacebookPage(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": updated.ID,
|
||||
"account_id": updated.AccountID,
|
||||
"inbox_id": updated.InboxID,
|
||||
"page_id": updated.PageID,
|
||||
"page_name": updated.PageName,
|
||||
"id": updated.ID,
|
||||
"account_id": updated.AccountID,
|
||||
"inbox_id": updated.InboxID,
|
||||
"page_id": updated.PageID,
|
||||
"page_name": updated.PageName,
|
||||
"reauthorization_required": updated.ReauthorizationRequired,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -107,6 +107,13 @@ func (h *GoogleChannelHandler) OAuthCallback(c *gin.Context) {
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
if err := h.inboxSvc.EnsureCanCreateInbox(ctx, uint(accountID)); err != nil {
|
||||
if renderInboxLimitExceeded(c, err) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to create google inbox"})
|
||||
return
|
||||
}
|
||||
|
||||
// Exchange the code for tokens
|
||||
tokenResult, err := h.goProvider.ExchangeToken(ctx, req.Code, req.RedirectURL)
|
||||
@@ -146,6 +153,12 @@ func (h *GoogleChannelHandler) OAuthCallback(c *gin.Context) {
|
||||
inbox, err := h.inboxSvc.Create(ctx, uint(accountID), inboxReq)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Failed to create Google inbox: %v", err)
|
||||
if delErr := h.goService.Delete(ctx, goChannel.ID); delErr != nil {
|
||||
applogger.L().Warnf("Failed to rollback Google channel after inbox creation failure: %v", delErr)
|
||||
}
|
||||
if renderInboxLimitExceeded(c, err) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to create google inbox"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -134,6 +134,9 @@ func (h *InboxHandler) Create(c *gin.Context) {
|
||||
|
||||
inbox, svcErr := h.svc.Create(c.Request.Context(), accountID, req)
|
||||
if svcErr != nil {
|
||||
if renderInboxLimitExceeded(c, svcErr) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to create inbox"})
|
||||
return
|
||||
}
|
||||
@@ -239,6 +242,14 @@ func (h *InboxHandler) Delete(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Your inbox deletion request will be processed in some time."})
|
||||
}
|
||||
|
||||
func renderInboxLimitExceeded(c *gin.Context, err error) bool {
|
||||
if !service.IsInboxLimitExceeded(err) {
|
||||
return false
|
||||
}
|
||||
c.JSON(http.StatusPaymentRequired, gin.H{"error": service.InboxLimitExceededMessage})
|
||||
return true
|
||||
}
|
||||
|
||||
func bindCreateInboxRequest(c *gin.Context, req *service.CreateInboxRequest) error {
|
||||
if strings.Contains(c.ContentType(), "json") {
|
||||
return bindCreateInboxJSON(c, req)
|
||||
|
||||
@@ -243,6 +243,40 @@ func TestInboxHandler_ChatwootCreateUpdateRequestBinding(t *testing.T) {
|
||||
require.Len(t, inboxParityObject(t, showUpdated)["working_hours"].([]any), 7)
|
||||
}
|
||||
|
||||
func TestInboxHandler_ChatwootCreateRejectsAccountInboxLimit(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
db, err := gorm.Open(sqlite.Open("file:inbox_handler_limit?mode=memory&cache=shared"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
sqlDB, dbErr := db.DB()
|
||||
if dbErr == nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
require.NoError(t, db.AutoMigrate(&model.Account{}, &model.Inbox{}, &model.WorkingHour{}))
|
||||
|
||||
account := &model.Account{Name: "Inbox Limit", Locale: "en", Active: true, InboxLimit: 1}
|
||||
require.NoError(t, db.Create(account).Error)
|
||||
require.NoError(t, db.Create(&model.Inbox{AccountID: account.ID, Name: "Existing", ChannelType: "api"}).Error)
|
||||
router := setupInboxParityRouter(db)
|
||||
|
||||
response := inboxParityRequest(t, router, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/inboxes/", account.ID), map[string]any{
|
||||
"name": "Blocked API",
|
||||
"channel": map[string]any{
|
||||
"type": "api",
|
||||
},
|
||||
})
|
||||
|
||||
require.Equal(t, http.StatusPaymentRequired, response.Code, response.Body.String())
|
||||
require.Equal(t, service.InboxLimitExceededMessage, inboxParityObject(t, response)["error"])
|
||||
var count int64
|
||||
require.NoError(t, db.Model(&model.Inbox{}).Where("account_id = ?", account.ID).Count(&count).Error)
|
||||
require.Equal(t, int64(1), count)
|
||||
}
|
||||
|
||||
func TestInboxHandler_ChatwootChannelSpecificConfigDepth(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
|
||||
@@ -35,10 +35,10 @@ import (
|
||||
|
||||
// InstagramChannelHandler handles Instagram DM channel management.
|
||||
type InstagramChannelHandler struct {
|
||||
igService *service.ChannelInstagramService
|
||||
igProvider *facebookchannel.InstagramProvider
|
||||
inboxSvc *service.InboxService
|
||||
igRepo *repository.ChannelInstagramRepo
|
||||
igService *service.ChannelInstagramService
|
||||
igProvider *facebookchannel.InstagramProvider
|
||||
inboxSvc *service.InboxService
|
||||
igRepo *repository.ChannelInstagramRepo
|
||||
}
|
||||
|
||||
// NewInstagramChannelHandler creates a new Instagram channel handler.
|
||||
@@ -203,13 +203,13 @@ func (h *InstagramChannelHandler) OAuthCallbackGET(c *gin.Context) {
|
||||
// After OAuth, the frontend calls this endpoint with the page_access_token and
|
||||
// IG account details to create the inbox and link the channel.
|
||||
type CreateInstagramChannelRequest struct {
|
||||
Name string `json:"name" validate:"required,min=2"`
|
||||
InstagramAccountID string `json:"instagram_account_id" validate:"required"`
|
||||
PageAccessToken string `json:"page_access_token" validate:"required"`
|
||||
ConnectedFBPageID string `json:"connected_fb_page_id" validate:"required"`
|
||||
Name string `json:"name" validate:"required,min=2"`
|
||||
InstagramAccountID string `json:"instagram_account_id" validate:"required"`
|
||||
PageAccessToken string `json:"page_access_token" validate:"required"`
|
||||
ConnectedFBPageID string `json:"connected_fb_page_id" validate:"required"`
|
||||
InstagramBusinessAccountID string `json:"instagram_business_account_id,omitempty"`
|
||||
InstagramAccountName string `json:"instagram_account_name,omitempty"`
|
||||
EnableAutoAssignment bool `json:"enable_auto_assignment,omitempty"`
|
||||
InstagramAccountName string `json:"instagram_account_name,omitempty"`
|
||||
EnableAutoAssignment bool `json:"enable_auto_assignment,omitempty"`
|
||||
}
|
||||
|
||||
// === Webhook Registration (G10) ===
|
||||
@@ -307,18 +307,21 @@ func (h *InstagramChannelHandler) CreateInstagramChannel(c *gin.Context) {
|
||||
|
||||
// Delegate to InboxService.CreateInstagramInbox (which creates both Inbox + ChannelInstagram)
|
||||
inboxReq := service.CreateInstagramInboxRequest{
|
||||
Name: req.Name,
|
||||
InstagramAccountID: req.InstagramAccountID,
|
||||
PageAccessToken: req.PageAccessToken,
|
||||
ConnectedFBPageID: req.ConnectedFBPageID,
|
||||
Name: req.Name,
|
||||
InstagramAccountID: req.InstagramAccountID,
|
||||
PageAccessToken: req.PageAccessToken,
|
||||
ConnectedFBPageID: req.ConnectedFBPageID,
|
||||
InstagramBusinessAccountID: req.InstagramBusinessAccountID,
|
||||
InstagramAccountName: req.InstagramAccountName,
|
||||
EnableAutoAssignment: req.EnableAutoAssignment,
|
||||
InstagramAccountName: req.InstagramAccountName,
|
||||
EnableAutoAssignment: req.EnableAutoAssignment,
|
||||
}
|
||||
|
||||
inbox, err := h.inboxSvc.CreateInstagramInbox(ctx, uint(accountID), inboxReq, h.igRepo)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Failed to create Instagram inbox: %v", err)
|
||||
if renderInboxLimitExceeded(c, err) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to create instagram channel"})
|
||||
return
|
||||
}
|
||||
@@ -330,11 +333,11 @@ func (h *InstagramChannelHandler) CreateInstagramChannel(c *gin.Context) {
|
||||
// Still return success — inbox was created, just couldn't fetch IG record
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"inbox": gin.H{
|
||||
"id": inbox.ID,
|
||||
"name": inbox.Name,
|
||||
"channel_type": inbox.ChannelType,
|
||||
"channel_id": inbox.ChannelID,
|
||||
"enabled": inbox.Enabled,
|
||||
"id": inbox.ID,
|
||||
"name": inbox.Name,
|
||||
"channel_type": inbox.ChannelType,
|
||||
"channel_id": inbox.ChannelID,
|
||||
"enabled": inbox.Enabled,
|
||||
"enable_auto_assignment": inbox.EnableAutoAssignment,
|
||||
},
|
||||
})
|
||||
@@ -343,19 +346,19 @@ func (h *InstagramChannelHandler) CreateInstagramChannel(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"inbox": gin.H{
|
||||
"id": inbox.ID,
|
||||
"name": inbox.Name,
|
||||
"channel_type": inbox.ChannelType,
|
||||
"channel_id": inbox.ChannelID,
|
||||
"enabled": inbox.Enabled,
|
||||
"id": inbox.ID,
|
||||
"name": inbox.Name,
|
||||
"channel_type": inbox.ChannelType,
|
||||
"channel_id": inbox.ChannelID,
|
||||
"enabled": inbox.Enabled,
|
||||
"enable_auto_assignment": inbox.EnableAutoAssignment,
|
||||
},
|
||||
"instagram_channel": gin.H{
|
||||
"id": igChannel.ID,
|
||||
"instagram_account_id": igChannel.InstagramAccountID,
|
||||
"id": igChannel.ID,
|
||||
"instagram_account_id": igChannel.InstagramAccountID,
|
||||
"instagram_business_account_id": igChannel.InstagramBusinessAccountID,
|
||||
"connected_fb_page_id": igChannel.ConnectedFBPageID,
|
||||
"instagram_account_name": igChannel.InstagramAccountName,
|
||||
"connected_fb_page_id": igChannel.ConnectedFBPageID,
|
||||
"instagram_account_name": igChannel.InstagramAccountName,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -478,8 +481,8 @@ func (h *InstagramChannelHandler) Reauthorize(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "instagram channels reauthorized successfully",
|
||||
"updated_count": updatedCount,
|
||||
"message": "instagram channels reauthorized successfully",
|
||||
"updated_count": updatedCount,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -524,14 +527,14 @@ func (h *InstagramChannelHandler) GetInstagramChannel(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"instagram_channel": gin.H{
|
||||
"id": igChannel.ID,
|
||||
"account_id": igChannel.AccountID,
|
||||
"inbox_id": igChannel.InboxID,
|
||||
"instagram_account_id": igChannel.InstagramAccountID,
|
||||
"id": igChannel.ID,
|
||||
"account_id": igChannel.AccountID,
|
||||
"inbox_id": igChannel.InboxID,
|
||||
"instagram_account_id": igChannel.InstagramAccountID,
|
||||
"instagram_business_account_id": igChannel.InstagramBusinessAccountID,
|
||||
"connected_fb_page_id": igChannel.ConnectedFBPageID,
|
||||
"instagram_account_name": igChannel.InstagramAccountName,
|
||||
"reauthorization_required": igChannel.ReauthorizationRequired,
|
||||
"connected_fb_page_id": igChannel.ConnectedFBPageID,
|
||||
"instagram_account_name": igChannel.InstagramAccountName,
|
||||
"reauthorization_required": igChannel.ReauthorizationRequired,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -560,13 +563,13 @@ func (h *InstagramChannelHandler) ListInstagramChannels(c *gin.Context) {
|
||||
result := make([]gin.H, 0, len(channels))
|
||||
for _, ch := range channels {
|
||||
result = append(result, gin.H{
|
||||
"id": ch.ID,
|
||||
"inbox_id": ch.InboxID,
|
||||
"instagram_account_id": ch.InstagramAccountID,
|
||||
"id": ch.ID,
|
||||
"inbox_id": ch.InboxID,
|
||||
"instagram_account_id": ch.InstagramAccountID,
|
||||
"instagram_business_account_id": ch.InstagramBusinessAccountID,
|
||||
"connected_fb_page_id": ch.ConnectedFBPageID,
|
||||
"instagram_account_name": ch.InstagramAccountName,
|
||||
"reauthorization_required": ch.ReauthorizationRequired,
|
||||
"connected_fb_page_id": ch.ConnectedFBPageID,
|
||||
"instagram_account_name": ch.InstagramAccountName,
|
||||
"reauthorization_required": ch.ReauthorizationRequired,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -909,12 +912,12 @@ func (h *InstagramChannelHandler) UpdateInstagramChannel(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": updated.ID,
|
||||
"account_id": updated.AccountID,
|
||||
"inbox_id": updated.InboxID,
|
||||
"instagram_account_id": updated.InstagramAccountID,
|
||||
"instagram_account_name": updated.InstagramAccountName,
|
||||
"connected_fb_page_id": updated.ConnectedFBPageID,
|
||||
"id": updated.ID,
|
||||
"account_id": updated.AccountID,
|
||||
"inbox_id": updated.InboxID,
|
||||
"instagram_account_id": updated.InstagramAccountID,
|
||||
"instagram_account_name": updated.InstagramAccountName,
|
||||
"connected_fb_page_id": updated.ConnectedFBPageID,
|
||||
"reauthorization_required": updated.ReauthorizationRequired,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,13 @@ func (h *LINEChannelHandler) Create(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
if err := h.inboxSvc.EnsureCanCreateInbox(c.Request.Context(), accountID); err != nil {
|
||||
if renderInboxLimitExceeded(c, err) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to create inbox"})
|
||||
return
|
||||
}
|
||||
|
||||
ch := &channelmodel.ChannelLINE{
|
||||
AccountID: accountID,
|
||||
@@ -103,6 +110,12 @@ func (h *LINEChannelHandler) Create(c *gin.Context) {
|
||||
inbox, err := h.inboxSvc.Create(c.Request.Context(), accountID, inboxReq)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Failed to create inbox for LINE channel: %v", err)
|
||||
if delErr := h.lineChannelSvc.Delete(c.Request.Context(), ch.ID); delErr != nil {
|
||||
applogger.L().Warnf("Failed to rollback LINE channel after inbox creation failure: %v", delErr)
|
||||
}
|
||||
if renderInboxLimitExceeded(c, err) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to create inbox"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -121,6 +121,38 @@ func TestLINEChannel_Create_Success(t *testing.T) {
|
||||
require.NotContains(t, resp, "inbox")
|
||||
}
|
||||
|
||||
func TestLINEChannel_CreateRejectsAccountInboxLimitWithoutChannelOrphan(t *testing.T) {
|
||||
handler, db := setupLINEHandlerTest(t)
|
||||
router := setupLINETestRouter(handler)
|
||||
accountID := lineAccountIDUint(db)
|
||||
require.NoError(t, db.Model(&model.Account{}).Where("id = ?", accountID).Update("inbox_limit", 1).Error)
|
||||
require.NoError(t, db.Create(&model.Inbox{AccountID: accountID, Name: "Existing", ChannelType: "api"}).Error)
|
||||
|
||||
body := CreateLINEChannelRequest{
|
||||
ChannelID: "line_limit_123",
|
||||
Name: "Blocked LINE",
|
||||
ChannelAccessToken: "access_token_secret",
|
||||
ChannelSecret: "secret_value",
|
||||
}
|
||||
b, _ := json.Marshal(body)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodPost, "/api/v1/accounts/"+strconv.FormatUint(uint64(accountID), 10)+"/line_channel", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusPaymentRequired, w.Code, w.Body.String())
|
||||
var resp map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
require.Equal(t, service.InboxLimitExceededMessage, resp["error"])
|
||||
var inboxCount int64
|
||||
require.NoError(t, db.Model(&model.Inbox{}).Where("account_id = ?", accountID).Count(&inboxCount).Error)
|
||||
require.Equal(t, int64(1), inboxCount)
|
||||
var channelCount int64
|
||||
require.NoError(t, db.Model(&channelmodel.ChannelLINE{}).Where("account_id = ?", accountID).Count(&channelCount).Error)
|
||||
require.Equal(t, int64(0), channelCount)
|
||||
}
|
||||
|
||||
func TestLINEChannel_Create_InvalidAccountID(t *testing.T) {
|
||||
handler, _ := setupLINEHandlerTest(t)
|
||||
router := setupLINETestRouter(handler)
|
||||
|
||||
@@ -108,6 +108,13 @@ func (h *MicrosoftChannelHandler) OAuthCallback(c *gin.Context) {
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
if err := h.inboxSvc.EnsureCanCreateInbox(ctx, uint(accountID)); err != nil {
|
||||
if renderInboxLimitExceeded(c, err) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to create microsoft inbox"})
|
||||
return
|
||||
}
|
||||
|
||||
// Exchange the code for tokens
|
||||
tokenResult, err := h.msProvider.ExchangeToken(ctx, req.Code, req.RedirectURL)
|
||||
@@ -148,6 +155,12 @@ func (h *MicrosoftChannelHandler) OAuthCallback(c *gin.Context) {
|
||||
inbox, err := h.inboxSvc.Create(ctx, uint(accountID), inboxReq)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Failed to create Microsoft inbox: %v", err)
|
||||
if delErr := h.msService.Delete(ctx, msChannel.ID); delErr != nil {
|
||||
applogger.L().Warnf("Failed to rollback Microsoft channel after inbox creation failure: %v", delErr)
|
||||
}
|
||||
if renderInboxLimitExceeded(c, err) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to create microsoft inbox"})
|
||||
return
|
||||
}
|
||||
@@ -165,8 +178,8 @@ func (h *MicrosoftChannelHandler) OAuthCallback(c *gin.Context) {
|
||||
"channel_type": inbox.ChannelType,
|
||||
},
|
||||
"microsoft_channel": gin.H{
|
||||
"id": msChannel.ID,
|
||||
"tenant_id": msChannel.TenantID,
|
||||
"id": msChannel.ID,
|
||||
"tenant_id": msChannel.TenantID,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -80,12 +80,19 @@ func (h *TikTokChannelHandler) CreateTikTokChannel(c *gin.Context) {
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
if err := h.inboxSvc.EnsureCanCreateInbox(ctx, uint(accountID)); err != nil {
|
||||
if renderInboxLimitExceeded(c, err) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to create TikTok inbox", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 1. Create ChannelTikTok record
|
||||
channelRecord := &channelmodel.ChannelTikTok{
|
||||
AccountID: uint(accountID),
|
||||
AccountID: uint(accountID),
|
||||
TikTokBusinessID: req.TikTokBusinessID,
|
||||
AccessToken: req.AccessToken,
|
||||
AccessToken: req.AccessToken,
|
||||
}
|
||||
|
||||
if err := h.ttChannelSvc.Create(ctx, channelRecord); err != nil {
|
||||
@@ -107,11 +114,17 @@ func (h *TikTokChannelHandler) CreateTikTokChannel(c *gin.Context) {
|
||||
})
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Failed to create TikTok inbox: %v", err)
|
||||
if delErr := h.ttChannelSvc.Delete(ctx, channelRecord.ID); delErr != nil {
|
||||
applogger.L().Warnf("Failed to rollback TikTok channel after inbox creation failure: %v", delErr)
|
||||
}
|
||||
if renderInboxLimitExceeded(c, err) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to create TikTok inbox", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Link ChannelTikTok.InboxID = createdInbox.ID
|
||||
// 3. Link ChannelTikTok.InboxID = createdInbox.ID
|
||||
channelRecord.InboxID = createdInbox.ID
|
||||
if err := h.ttChannelSvc.Update(ctx, channelRecord); err != nil {
|
||||
applogger.L().Errorf("Failed to update TikTok channel inbox_id: %v", err)
|
||||
@@ -140,17 +153,17 @@ func (h *TikTokChannelHandler) CreateTikTokChannel(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"channel": gin.H{
|
||||
"id": channelRecord.ID,
|
||||
"account_id": channelRecord.AccountID,
|
||||
"inbox_id": channelRecord.InboxID,
|
||||
"tiktok_business_id": channelRecord.TikTokBusinessID,
|
||||
"id": channelRecord.ID,
|
||||
"account_id": channelRecord.AccountID,
|
||||
"inbox_id": channelRecord.InboxID,
|
||||
"tiktok_business_id": channelRecord.TikTokBusinessID,
|
||||
},
|
||||
"inbox": gin.H{
|
||||
"id": createdInbox.ID,
|
||||
"name": createdInbox.Name,
|
||||
"channel_type": createdInbox.ChannelType,
|
||||
"channel_id": createdInbox.ChannelID,
|
||||
"enabled": createdInbox.Enabled,
|
||||
"id": createdInbox.ID,
|
||||
"name": createdInbox.Name,
|
||||
"channel_type": createdInbox.ChannelType,
|
||||
"channel_id": createdInbox.ChannelID,
|
||||
"enabled": createdInbox.Enabled,
|
||||
"enable_auto_assignment": createdInbox.EnableAutoAssignment,
|
||||
},
|
||||
})
|
||||
@@ -189,20 +202,20 @@ func (h *TikTokChannelHandler) GetTikTokChannel(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": ch.ID,
|
||||
"account_id": ch.AccountID,
|
||||
"inbox_id": ch.InboxID,
|
||||
"tiktok_business_id": ch.TikTokBusinessID,
|
||||
"id": ch.ID,
|
||||
"account_id": ch.AccountID,
|
||||
"inbox_id": ch.InboxID,
|
||||
"tiktok_business_id": ch.TikTokBusinessID,
|
||||
"reauthorization_required": ch.ReauthorizationRequired,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateTikTokChannelRequest is the DTO for updating a TikTok channel.
|
||||
type UpdateTikTokChannelRequest struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
AccessToken string `json:"access_token,omitempty"`
|
||||
InboxName string `json:"inbox_name,omitempty"`
|
||||
EnableAutoAssignment *bool `json:"enable_auto_assignment,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
AccessToken string `json:"access_token,omitempty"`
|
||||
InboxName string `json:"inbox_name,omitempty"`
|
||||
EnableAutoAssignment *bool `json:"enable_auto_assignment,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateTikTokChannel updates a TikTok channel configuration.
|
||||
@@ -346,10 +359,10 @@ func (h *TikTokChannelHandler) ListTikTokChannels(c *gin.Context) {
|
||||
result := make([]gin.H, 0, len(channels))
|
||||
for _, ch := range channels {
|
||||
result = append(result, gin.H{
|
||||
"id": ch.ID,
|
||||
"account_id": ch.AccountID,
|
||||
"inbox_id": ch.InboxID,
|
||||
"tiktok_business_id": ch.TikTokBusinessID,
|
||||
"id": ch.ID,
|
||||
"account_id": ch.AccountID,
|
||||
"inbox_id": ch.InboxID,
|
||||
"tiktok_business_id": ch.TikTokBusinessID,
|
||||
"reauthorization_required": ch.ReauthorizationRequired,
|
||||
})
|
||||
}
|
||||
@@ -358,4 +371,4 @@ func (h *TikTokChannelHandler) ListTikTokChannels(c *gin.Context) {
|
||||
"channels": result,
|
||||
"count": len(result),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +81,13 @@ func (h *TwilioChannelHandler) Create(c *gin.Context) {
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
if err := h.inboxSvc.EnsureCanCreateInbox(ctx, accountID); err != nil {
|
||||
if renderInboxLimitExceeded(c, err) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to create inbox"})
|
||||
return
|
||||
}
|
||||
medium := firstNonEmptyString(req.Medium, "sms")
|
||||
phoneNumber := twilioPhoneNumberForMedium(req.PhoneNumber, medium)
|
||||
|
||||
@@ -127,6 +134,9 @@ func (h *TwilioChannelHandler) Create(c *gin.Context) {
|
||||
if delErr := h.twChannelSvc.Delete(ctx, ch.ID); delErr != nil {
|
||||
applogger.L().Warnf("Failed to rollback Twilio SMS channel after inbox creation failure: %v", delErr)
|
||||
}
|
||||
if renderInboxLimitExceeded(c, err) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to create inbox"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -123,6 +123,13 @@ func (h *TwitterChannelHandler) OAuthCallback(c *gin.Context) {
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
if err := h.inboxSvc.EnsureCanCreateInbox(ctx, uint(accountID)); err != nil {
|
||||
if renderInboxLimitExceeded(c, err) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to create twitter inbox"})
|
||||
return
|
||||
}
|
||||
|
||||
// Exchange the code for tokens (PKCE verifier stored in state)
|
||||
// For now, use empty verifier since state-based verifier retrieval requires session storage
|
||||
@@ -144,10 +151,10 @@ func (h *TwitterChannelHandler) OAuthCallback(c *gin.Context) {
|
||||
|
||||
// Create the Twitter channel record
|
||||
twChannel := &channelmodel.ChannelTwitter{
|
||||
AccountID: uint(accountID),
|
||||
AccessToken: tokenResult.AccessToken,
|
||||
RefreshToken: tokenResult.RefreshToken,
|
||||
WebhookEnv: h.twProvider.GetWebhookEnv(),
|
||||
AccountID: uint(accountID),
|
||||
AccessToken: tokenResult.AccessToken,
|
||||
RefreshToken: tokenResult.RefreshToken,
|
||||
WebhookEnv: h.twProvider.GetWebhookEnv(),
|
||||
}
|
||||
|
||||
if err := h.twService.Create(ctx, twChannel); err != nil {
|
||||
@@ -158,13 +165,19 @@ func (h *TwitterChannelHandler) OAuthCallback(c *gin.Context) {
|
||||
|
||||
// Create the inbox
|
||||
inboxReq := service.CreateInboxRequest{
|
||||
Name: req.Name,
|
||||
ChannelType: string(model.InboxChannelTypeTwitter),
|
||||
Name: req.Name,
|
||||
ChannelType: string(model.InboxChannelTypeTwitter),
|
||||
}
|
||||
|
||||
inbox, err := h.inboxSvc.Create(ctx, uint(accountID), inboxReq)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Failed to create Twitter inbox: %v", err)
|
||||
if delErr := h.twService.Delete(ctx, twChannel.ID); delErr != nil {
|
||||
applogger.L().Warnf("Failed to rollback Twitter channel after inbox creation failure: %v", delErr)
|
||||
}
|
||||
if renderInboxLimitExceeded(c, err) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to create twitter inbox"})
|
||||
return
|
||||
}
|
||||
@@ -400,9 +413,9 @@ func (h *TwitterChannelHandler) OAuthCallbackGET(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"channel_id": twChannel.ID,
|
||||
"access_token": twChannel.AccessToken,
|
||||
"webhook_env": twChannel.WebhookEnv,
|
||||
"state": state,
|
||||
"channel_id": twChannel.ID,
|
||||
"access_token": twChannel.AccessToken,
|
||||
"webhook_env": twChannel.WebhookEnv,
|
||||
"state": state,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -47,6 +47,9 @@ func (h *WebWidgetHandler) CreateWebWidgetInbox(c *gin.Context) {
|
||||
inbox, err := h.inboxSvc.CreateWebWidgetInbox(c.Request.Context(), uint(accountID), req)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Failed to create web_widget inbox: %v", err)
|
||||
if renderInboxLimitExceeded(c, err) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -157,4 +160,4 @@ func (h *WebWidgetHandler) DeleteWebWidgetInbox(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"id": inboxID, "deleted": true})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user