From f04a03b65a6ae4eed677602526e82a909c8cb61b Mon Sep 17 00:00:00 2001 From: Rogee Date: Fri, 5 Jun 2026 06:19:50 +0800 Subject: [PATCH] feat(channels): align channel route inbox payloads --- .../v1/channel_handler_test_helpers_test.go | 29 ++++ .../handler/api/v1/email_channel_handler.go | 137 ++++++++++++------ .../api/v1/email_channel_handler_test.go | 80 ++++++---- .../handler/api/v1/line_channel_handler.go | 133 +++++++++++------ .../api/v1/line_channel_handler_test.go | 67 ++++++--- .../handler/api/v1/twilio_channel_handler.go | 105 ++++++++------ .../api/v1/twilio_channel_handler_test.go | 41 ++++-- internal/service/inbox_service.go | 23 +++ 8 files changed, 426 insertions(+), 189 deletions(-) create mode 100644 internal/handler/api/v1/channel_handler_test_helpers_test.go diff --git a/internal/handler/api/v1/channel_handler_test_helpers_test.go b/internal/handler/api/v1/channel_handler_test_helpers_test.go new file mode 100644 index 00000000..47727d16 --- /dev/null +++ b/internal/handler/api/v1/channel_handler_test_helpers_test.go @@ -0,0 +1,29 @@ +package v1 + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + "gorm.io/gorm" + + "github.com/gochat/gochat/internal/model" +) + +func createTestChannelInbox(t *testing.T, db *gorm.DB, accountID, channelID uint, name, channelType string, channelConfig map[string]any) *model.Inbox { + t.Helper() + + configJSON, err := json.Marshal(channelConfig) + require.NoError(t, err) + + inbox := &model.Inbox{ + AccountID: accountID, + Name: name, + ChannelType: channelType, + ChannelID: channelID, + Enabled: true, + ChannelConfig: string(configJSON), + } + require.NoError(t, db.Create(inbox).Error) + return inbox +} diff --git a/internal/handler/api/v1/email_channel_handler.go b/internal/handler/api/v1/email_channel_handler.go index 372763c6..3d2649d4 100644 --- a/internal/handler/api/v1/email_channel_handler.go +++ b/internal/handler/api/v1/email_channel_handler.go @@ -12,7 +12,6 @@ package v1 import ( "net/http" - "strconv" "strings" "github.com/gin-gonic/gin" @@ -71,10 +70,8 @@ type CreateEmailChannelRequest struct { // Create adds a new Email channel and creates the associated inbox. // POST /api/v1/accounts/:id/channels/email_channel func (h *EmailChannelHandler) Create(c *gin.Context) { - accountIDStr := c.Param("id") - accountID, err := strconv.ParseUint(accountIDStr, 10, 64) - if err != nil { - applogger.L().Errorf("Invalid account_id: %v", err) + accountID := parseAccountIDParam(c) + if accountID == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account_id"}) return } @@ -108,7 +105,7 @@ func (h *EmailChannelHandler) Create(c *gin.Context) { SMTPLogin: req.SMTPLogin, SMTPPassword: req.SMTPPassword, SMTPSSLMode: req.SMTPSSLMode, - AccountID: uint(accountID), + AccountID: accountID, } if err := h.emailChannelSvc.Create(ctx, ch); err != nil { @@ -131,7 +128,8 @@ func (h *EmailChannelHandler) Create(c *gin.Context) { Enabled: true, } - inbox, err := h.inboxSvc.Create(ctx, uint(accountID), inboxReq) + inboxReq.Channel = emailChannelConfig(req) + inbox, err := h.inboxSvc.Create(ctx, accountID, inboxReq) if err != nil { applogger.L().Errorf("Failed to create inbox for Email channel: %v", err) // Rollback channel creation @@ -147,32 +145,32 @@ func (h *EmailChannelHandler) Create(c *gin.Context) { if err := h.emailChannelSvc.Update(ctx, ch); err != nil { applogger.L().Errorf("Failed to update Email channel with inbox_id: %v", err) } + inbox, err = h.inboxSvc.BindChannel(ctx, accountID, inbox.ID, ch.ID, emailChannelConfig(req)) + if err != nil { + applogger.L().Warnf("Failed to bind Email inbox channel config: %v", err) + } - c.JSON(http.StatusCreated, gin.H{ - "channel": ch, - "inbox": inbox, - }) + c.JSON(http.StatusOK, serializeInbox(inbox)) } // Get retrieves an Email channel by ID. // GET /api/v1/accounts/:id/channels/email_channel/:em_id func (h *EmailChannelHandler) Get(c *gin.Context) { - emIDStr := c.Param("em_id") - emID, err := strconv.ParseUint(emIDStr, 10, 64) + emID, err := parseUintParam(c, "em_id") if err != nil { applogger.L().Errorf("Invalid em_id: %v", err) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid em_id"}) return } - ch, err := h.emailChannelSvc.GetByID(c.Request.Context(), uint(emID)) + ch, err := h.emailChannelSvc.GetByID(c.Request.Context(), emID) if err != nil { applogger.L().Errorf("Failed to get Email channel: %v", err) c.JSON(http.StatusNotFound, gin.H{"error": "Email channel not found"}) return } - c.JSON(http.StatusOK, gin.H{"channel": ch}) + c.JSON(http.StatusOK, h.serializeInboxForEmailChannel(c, ch)) } // UpdateEmailChannelRequest is the DTO for updating an Email channel. @@ -198,15 +196,13 @@ type UpdateEmailChannelRequest struct { // Update updates an Email channel configuration. // PATCH /api/v1/accounts/:id/channels/email_channel/:em_id func (h *EmailChannelHandler) Update(c *gin.Context) { - accountIDStr := c.Param("id") - accountID, err := strconv.ParseUint(accountIDStr, 10, 64) - if err != nil { + accountID := parseAccountIDParam(c) + if accountID == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account_id"}) return } - emIDStr := c.Param("em_id") - emID, err := strconv.ParseUint(emIDStr, 10, 64) + emID, err := parseUintParam(c, "em_id") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid em_id"}) return @@ -214,7 +210,7 @@ func (h *EmailChannelHandler) Update(c *gin.Context) { ctx := c.Request.Context() - ch, err := h.emailChannelSvc.GetByID(ctx, uint(emID)) + ch, err := h.emailChannelSvc.GetByID(ctx, emID) if err != nil { applogger.L().Errorf("Failed to get Email channel for update: %v", err) c.JSON(http.StatusNotFound, gin.H{"error": "Email channel not found"}) @@ -222,7 +218,7 @@ func (h *EmailChannelHandler) Update(c *gin.Context) { } // Verify account ownership - if ch.AccountID != uint(accountID) { + if ch.AccountID != accountID { c.JSON(http.StatusForbidden, gin.H{"error": "Email channel does not belong to this account"}) return } @@ -292,29 +288,30 @@ func (h *EmailChannelHandler) Update(c *gin.Context) { updateReq := service.UpdateInboxRequest{ Name: *req.InboxName, } - if _, inboxErr := h.inboxSvc.Update(ctx, uint(accountID), ch.InboxID, updateReq); inboxErr != nil { + if _, inboxErr := h.inboxSvc.Update(ctx, accountID, ch.InboxID, updateReq); inboxErr != nil { applogger.L().Warnf("Failed to update Email inbox name: %v", inboxErr) } } + inbox, bindErr := h.inboxSvc.BindChannel(ctx, accountID, ch.InboxID, ch.ID, emailUpdateChannelConfig(req, ch)) + if bindErr != nil { + applogger.L().Warnf("Failed to update Email inbox channel config: %v", bindErr) + c.JSON(http.StatusOK, h.serializeInboxForEmailChannel(c, ch)) + return + } - c.JSON(http.StatusOK, gin.H{ - "channel": ch, - "message": "Email channel updated successfully", - }) + c.JSON(http.StatusOK, serializeInbox(inbox)) } // Delete removes an Email channel and its associated inbox. // DELETE /api/v1/accounts/:id/channels/email_channel/:em_id func (h *EmailChannelHandler) Delete(c *gin.Context) { - accountIDStr := c.Param("id") - accountID, err := strconv.ParseUint(accountIDStr, 10, 64) - if err != nil { + accountID := parseAccountIDParam(c) + if accountID == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account_id"}) return } - emIDStr := c.Param("em_id") - emID, err := strconv.ParseUint(emIDStr, 10, 64) + emID, err := parseUintParam(c, "em_id") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid em_id"}) return @@ -322,20 +319,20 @@ func (h *EmailChannelHandler) Delete(c *gin.Context) { ctx := c.Request.Context() - ch, err := h.emailChannelSvc.GetByID(ctx, uint(emID)) + ch, err := h.emailChannelSvc.GetByID(ctx, emID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Email channel not found"}) return } // Verify account ownership - if ch.AccountID != uint(accountID) { + if ch.AccountID != accountID { c.JSON(http.StatusForbidden, gin.H{"error": "Email channel does not belong to this account"}) return } // Delete the channel record - if err := h.emailChannelSvc.Delete(ctx, uint(emID)); err != nil { + if err := h.emailChannelSvc.Delete(ctx, emID); err != nil { applogger.L().Errorf("Failed to delete Email channel: %v", err) c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to delete Email channel"}) return @@ -343,34 +340,88 @@ func (h *EmailChannelHandler) Delete(c *gin.Context) { // Delete the associated inbox if ch.InboxID > 0 { - if delErr := h.inboxSvc.DeleteByAccount(ctx, uint(accountID), ch.InboxID); delErr != nil { + if delErr := h.inboxSvc.DeleteByAccount(ctx, accountID, ch.InboxID); delErr != nil { applogger.L().Warnf("Failed to delete inbox for Email channel: %v", delErr) } } - c.JSON(http.StatusOK, gin.H{"message": "Email channel deleted successfully"}) + c.Status(http.StatusOK) } // List lists all Email channels for an account. // GET /api/v1/accounts/:id/channels/email_channel func (h *EmailChannelHandler) List(c *gin.Context) { - accountIDStr := c.Param("id") - accountID, err := strconv.ParseUint(accountIDStr, 10, 64) - if err != nil { + accountID := parseAccountIDParam(c) + if accountID == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account_id"}) return } ctx := c.Request.Context() - channels, err := h.emailChannelSvc.ListByAccount(ctx, uint(accountID)) + channels, err := h.emailChannelSvc.ListByAccount(ctx, accountID) if err != nil { applogger.L().Errorf("Failed to list Email channels: %v", err) c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to list Email channels"}) return } - c.JSON(http.StatusOK, gin.H{"channels": channels}) + payload := make([]map[string]any, 0, len(channels)) + for i := range channels { + payload = append(payload, h.serializeInboxForEmailChannel(c, &channels[i])) + } + c.JSON(http.StatusOK, gin.H{"payload": payload}) +} + +func (h *EmailChannelHandler) serializeInboxForEmailChannel(c *gin.Context, ch *channelmodel.ChannelEmail) map[string]any { + if ch != nil && ch.InboxID > 0 { + if inbox, err := h.inboxSvc.GetByAccountAndID(c.Request.Context(), ch.AccountID, ch.InboxID); err == nil { + return serializeInbox(inbox) + } + } + return gin.H{"id": ch.ID, "account_id": ch.AccountID, "inbox_id": ch.InboxID, "email": ch.Email} +} + +func emailChannelConfig(req CreateEmailChannelRequest) map[string]any { + return map[string]any{ + "email": req.Email, + "mailbox_name": req.MailboxName, + "domain": extractDomain(req.Email), + "imap_enabled": req.IMAPEnabled, + "imap_address": req.IMAPAddress, + "imap_port": req.IMAPPort, + "imap_login": req.IMAPLogin, + "imap_password": req.IMAPPassword, + "imap_ssl_mode": req.IMAPSSLMode, + "imap_folder": req.IMAPFolder, + "smtp_enabled": req.SMTPEnabled, + "smtp_address": req.SMTPAddress, + "smtp_port": req.SMTPPort, + "smtp_login": req.SMTPLogin, + "smtp_password": req.SMTPPassword, + "smtp_ssl_mode": req.SMTPSSLMode, + } +} + +func emailUpdateChannelConfig(_ UpdateEmailChannelRequest, ch *channelmodel.ChannelEmail) map[string]any { + return map[string]any{ + "email": ch.Email, + "mailbox_name": ch.MailboxName, + "domain": ch.Domain, + "imap_enabled": ch.IMAPEnabled, + "imap_address": ch.IMAPAddress, + "imap_port": ch.IMAPPort, + "imap_login": ch.IMAPLogin, + "imap_password": ch.IMAPPassword, + "imap_ssl_mode": ch.IMAPSSLMode, + "imap_folder": ch.IMAPFolder, + "smtp_enabled": ch.SMTPEnabled, + "smtp_address": ch.SMTPAddress, + "smtp_port": ch.SMTPPort, + "smtp_login": ch.SMTPLogin, + "smtp_password": ch.SMTPPassword, + "smtp_ssl_mode": ch.SMTPSSLMode, + } } // extractDomain extracts the domain portion from an email address. @@ -380,4 +431,4 @@ func extractDomain(email string) string { return parts[1] } return "" -} \ No newline at end of file +} diff --git a/internal/handler/api/v1/email_channel_handler_test.go b/internal/handler/api/v1/email_channel_handler_test.go index 4844f1d9..7e89e2cd 100644 --- a/internal/handler/api/v1/email_channel_handler_test.go +++ b/internal/handler/api/v1/email_channel_handler_test.go @@ -14,8 +14,8 @@ import ( "gorm.io/driver/sqlite" "gorm.io/gorm" - channelmodel "github.com/gochat/gochat/internal/model/channel" "github.com/gochat/gochat/internal/model" + channelmodel "github.com/gochat/gochat/internal/model/channel" "github.com/gochat/gochat/internal/channel/whatsapp" "github.com/gochat/gochat/internal/repository" @@ -112,13 +112,16 @@ func TestEmailChannel_Create_Success(t *testing.T) { req.Header.Set("Content-Type", "application/json") router.ServeHTTP(w, req) - assert.Equal(t, http.StatusCreated, w.Code) + assert.Equal(t, http.StatusOK, w.Code) var resp map[string]interface{} require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) - channelData, ok := resp["channel"].(map[string]interface{}) - require.True(t, ok) - assert.Equal(t, "support@example.com", channelData["email"]) + assert.Equal(t, "Support Email", resp["name"]) + assert.Equal(t, "Channel::Email", resp["channel_type"]) + assert.Equal(t, "support@example.com", resp["email"]) + assert.Equal(t, "imap.example.com", resp["imap_address"]) + require.NotContains(t, resp, "channel") + require.NotContains(t, resp, "inbox") } func TestEmailChannel_Create_InvalidAccountID(t *testing.T) { @@ -161,14 +164,19 @@ func TestEmailChannel_Get_Success(t *testing.T) { router := setupEmailTestRouter(handler) accountID := emailAccountIDUint(db) - // Seed a channel + // Seed a channel and its associated inbox. ch := &channelmodel.ChannelEmail{ Email: "get@example.com", AccountID: accountID, - InboxID: 300, IMAPEnabled: true, } require.NoError(t, db.Create(ch).Error) + inbox := createTestChannelInbox(t, db, accountID, ch.ID, "Get Email", "email", map[string]any{ + "email": "get@example.com", + "imap_enabled": true, + }) + ch.InboxID = inbox.ID + require.NoError(t, db.Save(ch).Error) w := httptest.NewRecorder() req, _ := http.NewRequest(http.MethodGet, "/api/v1/accounts/"+strconv.FormatUint(uint64(accountID), 10)+"/inboxes/1/email_channels/"+strconv.FormatUint(uint64(ch.ID), 10), nil) @@ -177,9 +185,10 @@ func TestEmailChannel_Get_Success(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code) var resp map[string]interface{} require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) - channelData, ok := resp["channel"].(map[string]interface{}) - require.True(t, ok) - assert.Equal(t, "get@example.com", channelData["email"]) + assert.Equal(t, "Get Email", resp["name"]) + assert.Equal(t, "Channel::Email", resp["channel_type"]) + assert.Equal(t, "get@example.com", resp["email"]) + require.NotContains(t, resp, "channel") } func TestEmailChannel_Get_InvalidID(t *testing.T) { @@ -216,14 +225,19 @@ func TestEmailChannel_Update_Success(t *testing.T) { router := setupEmailTestRouter(handler) accountID := emailAccountIDUint(db) - // Seed a channel + // Seed a channel and its associated inbox. ch := &channelmodel.ChannelEmail{ Email: "update@example.com", AccountID: accountID, - InboxID: 301, IMAPEnabled: true, } require.NoError(t, db.Create(ch).Error) + inbox := createTestChannelInbox(t, db, accountID, ch.ID, "Update Email", "email", map[string]any{ + "email": "update@example.com", + "imap_enabled": true, + }) + ch.InboxID = inbox.ID + require.NoError(t, db.Save(ch).Error) newEmail := "updated@example.com" body := UpdateEmailChannelRequest{ @@ -239,9 +253,9 @@ func TestEmailChannel_Update_Success(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code) var resp map[string]interface{} require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) - channelData, ok := resp["channel"].(map[string]interface{}) - require.True(t, ok) - assert.Equal(t, "updated@example.com", channelData["email"]) + assert.Equal(t, "Channel::Email", resp["channel_type"]) + assert.Equal(t, "updated@example.com", resp["email"]) + require.NotContains(t, resp, "channel") } func TestEmailChannel_Update_WrongAccount(t *testing.T) { @@ -251,9 +265,9 @@ func TestEmailChannel_Update_WrongAccount(t *testing.T) { // Seed a channel owned by a different account ch := &channelmodel.ChannelEmail{ - Email: "other@example.com", - AccountID: accountID + 999, - InboxID: 302, + Email: "other@example.com", + AccountID: accountID + 999, + InboxID: 302, } require.NoError(t, db.Create(ch).Error) @@ -293,9 +307,9 @@ func TestEmailChannel_Delete_Success(t *testing.T) { // Seed a channel ch := &channelmodel.ChannelEmail{ - Email: "delete@example.com", - AccountID: accountID, - InboxID: 303, + Email: "delete@example.com", + AccountID: accountID, + InboxID: 303, } require.NoError(t, db.Create(ch).Error) @@ -318,9 +332,9 @@ func TestEmailChannel_Delete_WrongAccount(t *testing.T) { // Seed a channel owned by a different account ch := &channelmodel.ChannelEmail{ - Email: "otherdel@example.com", - AccountID: accountID + 999, - InboxID: 304, + Email: "otherdel@example.com", + AccountID: accountID + 999, + InboxID: 304, } require.NoError(t, db.Create(ch).Error) @@ -350,14 +364,18 @@ func TestEmailChannel_List_Success(t *testing.T) { router := setupEmailTestRouter(handler) accountID := emailAccountIDUint(db) - // Seed multiple channels + // Seed multiple channels and their associated inboxes. for i := 0; i < 3; i++ { ch := &channelmodel.ChannelEmail{ - Email: "list" + strconv.Itoa(i) + "@example.com", - AccountID: accountID, - InboxID: uint(i + 400), // unique inbox IDs + Email: "list" + strconv.Itoa(i) + "@example.com", + AccountID: accountID, } require.NoError(t, db.Create(ch).Error) + inbox := createTestChannelInbox(t, db, accountID, ch.ID, "Email List "+strconv.Itoa(i), "email", map[string]any{ + "email": ch.Email, + }) + ch.InboxID = inbox.ID + require.NoError(t, db.Save(ch).Error) } w := httptest.NewRecorder() @@ -367,7 +385,7 @@ func TestEmailChannel_List_Success(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code) var resp map[string]interface{} require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) - channels, ok := resp["channels"].([]interface{}) + channels, ok := resp["payload"].([]interface{}) require.True(t, ok) assert.Len(t, channels, 3) } @@ -395,7 +413,7 @@ func TestEmailChannel_List_Empty(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code) var resp map[string]interface{} require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) - channels, ok := resp["channels"].([]interface{}) + channels, ok := resp["payload"].([]interface{}) require.True(t, ok) assert.Len(t, channels, 0) -} \ No newline at end of file +} diff --git a/internal/handler/api/v1/line_channel_handler.go b/internal/handler/api/v1/line_channel_handler.go index 62f12840..895f7fd6 100644 --- a/internal/handler/api/v1/line_channel_handler.go +++ b/internal/handler/api/v1/line_channel_handler.go @@ -14,7 +14,6 @@ package v1 import ( "net/http" - "strconv" "github.com/gin-gonic/gin" @@ -56,19 +55,17 @@ func NewLINEChannelHandler( // CreateLINEChannelRequest is the DTO for creating a LINE channel. type CreateLINEChannelRequest struct { - ChannelID string `json:"channel_id" validate:"required"` - Name string `json:"name" validate:"required"` + ChannelID string `json:"channel_id" validate:"required"` + Name string `json:"name" validate:"required"` ChannelAccessToken string `json:"channel_access_token" validate:"required"` - ChannelSecret string `json:"channel_secret" validate:"required"` + ChannelSecret string `json:"channel_secret" validate:"required"` } // Create adds a new LINE channel and creates the associated inbox. // POST /api/v1/accounts/:id/channels/line_channel func (h *LINEChannelHandler) Create(c *gin.Context) { - accountIDStr := c.Param("id") - accountID, err := strconv.ParseUint(accountIDStr, 10, 64) - if err != nil { - applogger.L().Errorf("Invalid account_id: %v", err) + accountID := parseAccountIDParam(c) + if accountID == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account_id"}) return } @@ -85,7 +82,7 @@ func (h *LINEChannelHandler) Create(c *gin.Context) { } ch := &channelmodel.ChannelLINE{ - AccountID: uint(accountID), + AccountID: accountID, ChannelID: req.ChannelID, Name: req.Name, } @@ -101,8 +98,9 @@ func (h *LINEChannelHandler) Create(c *gin.Context) { Name: req.Name, ChannelType: "line", Enabled: true, + Channel: lineCreateChannelConfig(req), } -inbox, err := h.inboxSvc.Create(c.Request.Context(), uint(accountID), inboxReq) + 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) c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to create inbox"}) @@ -113,11 +111,12 @@ inbox, err := h.inboxSvc.Create(c.Request.Context(), uint(accountID), inboxReq) if err := h.lineChannelSvc.Update(c.Request.Context(), ch); err != nil { applogger.L().Errorf("Failed to update LINE channel with inbox_id: %v", err) } + inbox, err = h.inboxSvc.BindChannel(c.Request.Context(), accountID, inbox.ID, ch.ID, inboxReq.Channel) + if err != nil { + applogger.L().Warnf("Failed to bind LINE inbox channel config: %v", err) + } - c.JSON(http.StatusCreated, gin.H{ - "channel": ch, - "inbox": inbox, - }) + c.JSON(http.StatusOK, serializeInbox(inbox)) } // === Get === @@ -125,58 +124,56 @@ inbox, err := h.inboxSvc.Create(c.Request.Context(), uint(accountID), inboxReq) // Get retrieves a LINE channel by ID. // GET /api/v1/accounts/:id/channels/line_channel/:line_id func (h *LINEChannelHandler) Get(c *gin.Context) { - lineIDStr := c.Param("line_id") - lineID, err := strconv.ParseUint(lineIDStr, 10, 64) + lineID, err := parseUintParam(c, "line_id") if err != nil { applogger.L().Errorf("Invalid line_id: %v", err) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid line_id"}) return } - ch, err := h.lineChannelSvc.GetByID(c.Request.Context(), uint(lineID)) + ch, err := h.lineChannelSvc.GetByID(c.Request.Context(), lineID) if err != nil { applogger.L().Errorf("Failed to get LINE channel: %v", err) c.JSON(http.StatusNotFound, gin.H{"error": "LINE channel not found"}) return } - c.JSON(http.StatusOK, gin.H{"channel": ch}) + c.JSON(http.StatusOK, h.serializeInboxForLINEChannel(c, ch)) } // === Update === // UpdateLINEChannelRequest is the DTO for updating a LINE channel. type UpdateLINEChannelRequest struct { - Name string `json:"name,omitempty"` + Name string `json:"name,omitempty"` ChannelAccessToken string `json:"channel_access_token,omitempty"` - ChannelSecret string `json:"channel_secret,omitempty"` + ChannelSecret string `json:"channel_secret,omitempty"` } // Update modifies an existing LINE channel. // PATCH /api/v1/accounts/:id/channels/line_channel/:line_id func (h *LINEChannelHandler) Update(c *gin.Context) { - accountID, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { + accountID := parseAccountIDParam(c) + if accountID == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account_id"}) return } - lineIDStr := c.Param("line_id") - lineID, err := strconv.ParseUint(lineIDStr, 10, 64) + lineID, err := parseUintParam(c, "line_id") if err != nil { applogger.L().Errorf("Invalid line_id: %v", err) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid line_id"}) return } - ch, err := h.lineChannelSvc.GetByID(c.Request.Context(), uint(lineID)) + ch, err := h.lineChannelSvc.GetByID(c.Request.Context(), lineID) if err != nil { applogger.L().Errorf("Failed to get LINE channel: %v", err) c.JSON(http.StatusNotFound, gin.H{"error": "LINE channel not found"}) return } - if ch.AccountID != uint(accountID) { + if ch.AccountID != accountID { c.JSON(http.StatusForbidden, gin.H{"error": "channel does not belong to this account"}) return } @@ -197,8 +194,19 @@ func (h *LINEChannelHandler) Update(c *gin.Context) { c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to update LINE channel"}) return } + if req.Name != "" { + if _, inboxErr := h.inboxSvc.Update(c.Request.Context(), accountID, ch.InboxID, service.UpdateInboxRequest{Name: req.Name}); inboxErr != nil { + applogger.L().Warnf("Failed to update LINE inbox name: %v", inboxErr) + } + } + inbox, bindErr := h.inboxSvc.BindChannel(c.Request.Context(), accountID, ch.InboxID, ch.ID, lineUpdateChannelConfig(req, ch)) + if bindErr != nil { + applogger.L().Warnf("Failed to update LINE inbox channel config: %v", bindErr) + c.JSON(http.StatusOK, h.serializeInboxForLINEChannel(c, ch)) + return + } - c.JSON(http.StatusOK, gin.H{"channel": ch}) + c.JSON(http.StatusOK, serializeInbox(inbox)) } // === Delete === @@ -206,28 +214,27 @@ func (h *LINEChannelHandler) Update(c *gin.Context) { // Delete removes a LINE channel and its associated inbox. // DELETE /api/v1/accounts/:id/channels/line_channel/:line_id func (h *LINEChannelHandler) Delete(c *gin.Context) { - accountID, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { + accountID := parseAccountIDParam(c) + if accountID == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account_id"}) return } - lineIDStr := c.Param("line_id") - lineID, err := strconv.ParseUint(lineIDStr, 10, 64) + lineID, err := parseUintParam(c, "line_id") if err != nil { applogger.L().Errorf("Invalid line_id: %v", err) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid line_id"}) return } - ch, err := h.lineChannelSvc.GetByID(c.Request.Context(), uint(lineID)) + ch, err := h.lineChannelSvc.GetByID(c.Request.Context(), lineID) if err != nil { applogger.L().Errorf("Failed to get LINE channel: %v", err) c.JSON(http.StatusNotFound, gin.H{"error": "LINE channel not found"}) return } - if ch.AccountID != uint(accountID) { + if ch.AccountID != accountID { c.JSON(http.StatusForbidden, gin.H{"error": "channel does not belong to this account"}) return } @@ -239,13 +246,13 @@ func (h *LINEChannelHandler) Delete(c *gin.Context) { } } - if err := h.lineChannelSvc.Delete(c.Request.Context(), uint(lineID)); err != nil { + if err := h.lineChannelSvc.Delete(c.Request.Context(), lineID); err != nil { applogger.L().Errorf("Failed to delete LINE channel: %v", err) c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to delete LINE channel"}) return } - c.JSON(http.StatusOK, gin.H{"message": "LINE channel deleted"}) + c.Status(http.StatusOK) } // === List === @@ -253,10 +260,8 @@ func (h *LINEChannelHandler) Delete(c *gin.Context) { // List retrieves all LINE channels for an account. // GET /api/v1/accounts/:id/channels/line_channel func (h *LINEChannelHandler) List(c *gin.Context) { - accountIDStr := c.Param("id") - accountID, err := strconv.ParseUint(accountIDStr, 10, 64) - if err != nil { - applogger.L().Errorf("Invalid account_id: %v", err) + accountID := parseAccountIDParam(c) + if accountID == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account_id"}) return } @@ -268,5 +273,51 @@ func (h *LINEChannelHandler) List(c *gin.Context) { return } - c.JSON(http.StatusOK, gin.H{"channels": channels, "account_id": uint(accountID)}) -} \ No newline at end of file + payload := make([]map[string]any, 0, len(channels)) + for i := range channels { + if channels[i].AccountID == accountID { + payload = append(payload, h.serializeInboxForLINEChannel(c, &channels[i])) + } + } + c.JSON(http.StatusOK, gin.H{"payload": payload}) +} + +func (h *LINEChannelHandler) serializeInboxForLINEChannel(c *gin.Context, ch *channelmodel.ChannelLINE) map[string]any { + if ch != nil && ch.InboxID > 0 { + if inbox, err := h.inboxSvc.GetByAccountAndID(c.Request.Context(), ch.AccountID, ch.InboxID); err == nil { + return serializeInbox(inbox) + } + } + return gin.H{"id": ch.ID, "account_id": ch.AccountID, "inbox_id": ch.InboxID, "channel_id": ch.ChannelID, "name": ch.Name} +} + +func lineChannelConfigFromModel(ch *channelmodel.ChannelLINE) map[string]any { + return map[string]any{ + "line_channel_id": ch.ChannelID, + "channel_id": ch.ChannelID, + } +} + +func lineCreateChannelConfig(req CreateLINEChannelRequest) map[string]any { + return map[string]any{ + "line_channel_id": req.ChannelID, + "line_channel_token": req.ChannelAccessToken, + "line_channel_secret": req.ChannelSecret, + "channel_id": req.ChannelID, + "channel_access_token": req.ChannelAccessToken, + "channel_secret": req.ChannelSecret, + } +} + +func lineUpdateChannelConfig(req UpdateLINEChannelRequest, ch *channelmodel.ChannelLINE) map[string]any { + config := lineChannelConfigFromModel(ch) + if req.ChannelAccessToken != "" { + config["line_channel_token"] = req.ChannelAccessToken + config["channel_access_token"] = req.ChannelAccessToken + } + if req.ChannelSecret != "" { + config["line_channel_secret"] = req.ChannelSecret + config["channel_secret"] = req.ChannelSecret + } + return config +} diff --git a/internal/handler/api/v1/line_channel_handler_test.go b/internal/handler/api/v1/line_channel_handler_test.go index 4321df3a..93c7cb8e 100644 --- a/internal/handler/api/v1/line_channel_handler_test.go +++ b/internal/handler/api/v1/line_channel_handler_test.go @@ -14,8 +14,8 @@ import ( "gorm.io/driver/sqlite" "gorm.io/gorm" - channelmodel "github.com/gochat/gochat/internal/model/channel" "github.com/gochat/gochat/internal/model" + channelmodel "github.com/gochat/gochat/internal/model/channel" "github.com/gochat/gochat/internal/channel/whatsapp" "github.com/gochat/gochat/internal/repository" @@ -108,13 +108,17 @@ func TestLINEChannel_Create_Success(t *testing.T) { req.Header.Set("Content-Type", "application/json") router.ServeHTTP(w, req) - assert.Equal(t, http.StatusCreated, w.Code) + assert.Equal(t, http.StatusOK, w.Code) var resp map[string]interface{} require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) - channelData, ok := resp["channel"].(map[string]interface{}) - require.True(t, ok) - assert.Equal(t, "line_chan_123", channelData["channel_id"]) + assert.Equal(t, "LINE Official", resp["name"]) + assert.Equal(t, "Channel::Line", resp["channel_type"]) + assert.Equal(t, "line_chan_123", resp["line_channel_id"]) + assert.Equal(t, "secret_value", resp["line_channel_secret"]) + assert.Equal(t, "access_token_secret", resp["line_channel_token"]) + require.NotContains(t, resp, "channel") + require.NotContains(t, resp, "inbox") } func TestLINEChannel_Create_InvalidAccountID(t *testing.T) { @@ -160,14 +164,21 @@ func TestLINEChannel_Get_Success(t *testing.T) { router := setupLINETestRouter(handler) accountID := lineAccountIDUint(db) - // Seed a channel + // Seed a channel and its associated inbox. ch := &channelmodel.ChannelLINE{ ChannelID: "line_get_123", Name: "LINE Get Test", AccountID: accountID, - InboxID: 700, } require.NoError(t, db.Create(ch).Error) + inbox := createTestChannelInbox(t, db, accountID, ch.ID, "LINE Get Test", "line", map[string]any{ + "line_channel_id": "line_get_123", + "line_channel_secret": "line-secret", + "line_channel_token": "line-token", + "channel_secret": "line-secret", + }) + ch.InboxID = inbox.ID + require.NoError(t, db.Save(ch).Error) w := httptest.NewRecorder() req, _ := http.NewRequest(http.MethodGet, "/api/v1/accounts/"+strconv.FormatUint(uint64(accountID), 10)+"/inboxes/1/line_channel/"+strconv.FormatUint(uint64(ch.ID), 10), nil) @@ -176,9 +187,10 @@ func TestLINEChannel_Get_Success(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code) var resp map[string]interface{} require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) - channelData, ok := resp["channel"].(map[string]interface{}) - require.True(t, ok) - assert.Equal(t, "line_get_123", channelData["channel_id"]) + assert.Equal(t, "LINE Get Test", resp["name"]) + assert.Equal(t, "Channel::Line", resp["channel_type"]) + assert.Equal(t, "line_get_123", resp["line_channel_id"]) + require.NotContains(t, resp, "channel") } func TestLINEChannel_Get_InvalidID(t *testing.T) { @@ -215,14 +227,21 @@ func TestLINEChannel_Update_Success(t *testing.T) { router := setupLINETestRouter(handler) accountID := lineAccountIDUint(db) - // Seed a channel + // Seed a channel and its associated inbox. ch := &channelmodel.ChannelLINE{ ChannelID: "line_upd_123", Name: "LINE Update Test", AccountID: accountID, - InboxID: 701, } require.NoError(t, db.Create(ch).Error) + inbox := createTestChannelInbox(t, db, accountID, ch.ID, "LINE Update Test", "line", map[string]any{ + "line_channel_id": "line_upd_123", + "line_channel_secret": "old-secret", + "line_channel_token": "old-token", + "channel_secret": "old-secret", + }) + ch.InboxID = inbox.ID + require.NoError(t, db.Save(ch).Error) body := UpdateLINEChannelRequest{ Name: "Updated LINE", @@ -237,9 +256,10 @@ func TestLINEChannel_Update_Success(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code) var resp map[string]interface{} require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) - channelData, ok := resp["channel"].(map[string]interface{}) - require.True(t, ok) - assert.Equal(t, "Updated LINE", channelData["name"]) + assert.Equal(t, "Updated LINE", resp["name"]) + assert.Equal(t, "line_upd_123", resp["line_channel_id"]) + assert.Equal(t, "old-secret", resp["line_channel_secret"]) + require.NotContains(t, resp, "channel") } func TestLINEChannel_Update_WrongAccount(t *testing.T) { @@ -346,15 +366,22 @@ func TestLINEChannel_List_Success(t *testing.T) { router := setupLINETestRouter(handler) accountID := lineAccountIDUint(db) - // Seed multiple channels + // Seed multiple channels and their associated inboxes. for i := 0; i < 3; i++ { ch := &channelmodel.ChannelLINE{ ChannelID: "line_list_" + strconv.Itoa(i), Name: "LINE List " + strconv.Itoa(i), AccountID: accountID, - InboxID: uint(i + 800), // unique inbox IDs } require.NoError(t, db.Create(ch).Error) + inbox := createTestChannelInbox(t, db, accountID, ch.ID, ch.Name, "line", map[string]any{ + "line_channel_id": ch.ChannelID, + "line_channel_secret": "line-secret-" + strconv.Itoa(i), + "line_channel_token": "line-token-" + strconv.Itoa(i), + "channel_secret": "line-secret-" + strconv.Itoa(i), + }) + ch.InboxID = inbox.ID + require.NoError(t, db.Save(ch).Error) } w := httptest.NewRecorder() @@ -364,7 +391,7 @@ func TestLINEChannel_List_Success(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code) var resp map[string]interface{} require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) - channels, ok := resp["channels"].([]interface{}) + channels, ok := resp["payload"].([]interface{}) require.True(t, ok) assert.Len(t, channels, 3) } @@ -392,7 +419,7 @@ func TestLINEChannel_List_Empty(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code) var resp map[string]interface{} require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) - channels, ok := resp["channels"].([]interface{}) + channels, ok := resp["payload"].([]interface{}) require.True(t, ok) assert.Len(t, channels, 0) -} \ No newline at end of file +} diff --git a/internal/handler/api/v1/twilio_channel_handler.go b/internal/handler/api/v1/twilio_channel_handler.go index e277ec95..0acd506c 100644 --- a/internal/handler/api/v1/twilio_channel_handler.go +++ b/internal/handler/api/v1/twilio_channel_handler.go @@ -16,7 +16,6 @@ import ( "encoding/json" "io" "net/http" - "strconv" "strings" "github.com/gin-gonic/gin" @@ -64,10 +63,8 @@ type CreateTwilioSMSChannelRequest struct { // Create adds a new Twilio SMS channel and creates the associated inbox. // POST /api/v1/accounts/:id/channels/twilio_channel func (h *TwilioChannelHandler) Create(c *gin.Context) { - accountIDStr := c.Param("id") - accountID, err := strconv.ParseUint(accountIDStr, 10, 64) - if err != nil { - applogger.L().Errorf("Invalid account_id: %v", err) + accountID := parseAccountIDParam(c) + if accountID == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account_id"}) return } @@ -92,7 +89,7 @@ func (h *TwilioChannelHandler) Create(c *gin.Context) { AccountSID: req.AccountSID, PhoneNumber: phoneNumber, MessagingServiceSID: req.MessagingServiceSID, - AccountID: uint(accountID), + AccountID: accountID, } if err := h.twChannelSvc.Create(ctx, ch); err != nil { @@ -123,7 +120,7 @@ func (h *TwilioChannelHandler) Create(c *gin.Context) { }, } - inbox, err := h.inboxSvc.Create(ctx, uint(accountID), inboxReq) + inbox, err := h.inboxSvc.Create(ctx, accountID, inboxReq) if err != nil { applogger.L().Errorf("Failed to create inbox for Twilio SMS channel: %v", err) // Rollback channel creation @@ -139,6 +136,10 @@ func (h *TwilioChannelHandler) Create(c *gin.Context) { if err := h.twChannelSvc.Update(ctx, ch); err != nil { applogger.L().Errorf("Failed to update Twilio SMS channel with inbox_id: %v", err) } + inbox, err = h.inboxSvc.BindChannel(ctx, accountID, inbox.ID, ch.ID, inboxReq.Channel) + if err != nil { + applogger.L().Warnf("Failed to bind Twilio inbox channel config: %v", err) + } c.JSON(http.StatusOK, serializeInbox(inbox)) } @@ -183,22 +184,21 @@ func bindTwilioCreateRequest(c *gin.Context) (CreateTwilioSMSChannelRequest, err // Get retrieves a Twilio SMS channel by ID. // GET /api/v1/accounts/:id/channels/twilio_channel/:tw_id func (h *TwilioChannelHandler) Get(c *gin.Context) { - twIDStr := c.Param("tw_id") - twID, err := strconv.ParseUint(twIDStr, 10, 64) + twID, err := parseUintParam(c, "tw_id") if err != nil { applogger.L().Errorf("Invalid tw_id: %v", err) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid tw_id"}) return } - ch, err := h.twChannelSvc.GetByID(c.Request.Context(), uint(twID)) + ch, err := h.twChannelSvc.GetByID(c.Request.Context(), twID) if err != nil { applogger.L().Errorf("Failed to get Twilio SMS channel: %v", err) c.JSON(http.StatusNotFound, gin.H{"error": "Twilio SMS channel not found"}) return } - c.JSON(http.StatusOK, gin.H{"channel": ch}) + c.JSON(http.StatusOK, h.serializeInboxForTwilioChannel(c, ch)) } // UpdateTwilioSMSChannelRequest is the DTO for updating a Twilio SMS channel. @@ -211,15 +211,13 @@ type UpdateTwilioSMSChannelRequest struct { // Update updates a Twilio SMS channel configuration. // PATCH /api/v1/accounts/:id/channels/twilio_channel/:tw_id func (h *TwilioChannelHandler) Update(c *gin.Context) { - accountIDStr := c.Param("id") - accountID, err := strconv.ParseUint(accountIDStr, 10, 64) - if err != nil { + accountID := parseAccountIDParam(c) + if accountID == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account_id"}) return } - twIDStr := c.Param("tw_id") - twID, err := strconv.ParseUint(twIDStr, 10, 64) + twID, err := parseUintParam(c, "tw_id") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid tw_id"}) return @@ -227,7 +225,7 @@ func (h *TwilioChannelHandler) Update(c *gin.Context) { ctx := c.Request.Context() - ch, err := h.twChannelSvc.GetByID(ctx, uint(twID)) + ch, err := h.twChannelSvc.GetByID(ctx, twID) if err != nil { applogger.L().Errorf("Failed to get Twilio SMS channel for update: %v", err) c.JSON(http.StatusNotFound, gin.H{"error": "Twilio SMS channel not found"}) @@ -235,7 +233,7 @@ func (h *TwilioChannelHandler) Update(c *gin.Context) { } // Verify account ownership - if ch.AccountID != uint(accountID) { + if ch.AccountID != accountID { c.JSON(http.StatusForbidden, gin.H{"error": "Twilio SMS channel does not belong to this account"}) return } @@ -264,34 +262,30 @@ func (h *TwilioChannelHandler) Update(c *gin.Context) { updateReq := service.UpdateInboxRequest{ Name: *req.InboxName, } - if _, inboxErr := h.inboxSvc.Update(ctx, uint(accountID), ch.InboxID, updateReq); inboxErr != nil { + if _, inboxErr := h.inboxSvc.Update(ctx, accountID, ch.InboxID, updateReq); inboxErr != nil { applogger.L().Warnf("Failed to update Twilio SMS inbox name: %v", inboxErr) } } + inbox, bindErr := h.inboxSvc.BindChannel(ctx, accountID, ch.InboxID, ch.ID, twilioChannelConfigFromModel(ch)) + if bindErr != nil { + applogger.L().Warnf("Failed to update Twilio inbox channel config: %v", bindErr) + c.JSON(http.StatusOK, h.serializeInboxForTwilioChannel(c, ch)) + return + } - c.JSON(http.StatusOK, gin.H{ - "id": ch.ID, - "account_id": ch.AccountID, - "inbox_id": ch.InboxID, - "account_sid": ch.AccountSID, - "phone_number": ch.PhoneNumber, - "messaging_service_sid": ch.MessagingServiceSID, - "message": "Twilio SMS channel updated successfully", - }) + c.JSON(http.StatusOK, serializeInbox(inbox)) } // Delete removes a Twilio SMS channel and its associated inbox. // DELETE /api/v1/accounts/:id/channels/twilio_channel/:tw_id func (h *TwilioChannelHandler) Delete(c *gin.Context) { - accountIDStr := c.Param("id") - accountID, err := strconv.ParseUint(accountIDStr, 10, 64) - if err != nil { + accountID := parseAccountIDParam(c) + if accountID == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account_id"}) return } - twIDStr := c.Param("tw_id") - twID, err := strconv.ParseUint(twIDStr, 10, 64) + twID, err := parseUintParam(c, "tw_id") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid tw_id"}) return @@ -299,20 +293,20 @@ func (h *TwilioChannelHandler) Delete(c *gin.Context) { ctx := c.Request.Context() - ch, err := h.twChannelSvc.GetByID(ctx, uint(twID)) + ch, err := h.twChannelSvc.GetByID(ctx, twID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Twilio SMS channel not found"}) return } // Verify account ownership - if ch.AccountID != uint(accountID) { + if ch.AccountID != accountID { c.JSON(http.StatusForbidden, gin.H{"error": "Twilio SMS channel does not belong to this account"}) return } // Delete the channel record - if err := h.twChannelSvc.Delete(ctx, uint(twID)); err != nil { + if err := h.twChannelSvc.Delete(ctx, twID); err != nil { applogger.L().Errorf("Failed to delete Twilio SMS channel: %v", err) c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to delete Twilio SMS channel"}) return @@ -320,32 +314,57 @@ func (h *TwilioChannelHandler) Delete(c *gin.Context) { // Delete the associated inbox if ch.InboxID > 0 { - if delErr := h.inboxSvc.DeleteByAccount(ctx, uint(accountID), ch.InboxID); delErr != nil { + if delErr := h.inboxSvc.DeleteByAccount(ctx, accountID, ch.InboxID); delErr != nil { applogger.L().Warnf("Failed to delete inbox for Twilio SMS channel: %v", delErr) } } - c.JSON(http.StatusOK, gin.H{"message": "Twilio SMS channel deleted successfully"}) + c.Status(http.StatusOK) } // List lists all Twilio SMS channels for an account. // GET /api/v1/accounts/:id/channels/twilio_channel func (h *TwilioChannelHandler) List(c *gin.Context) { - accountIDStr := c.Param("id") - accountID, err := strconv.ParseUint(accountIDStr, 10, 64) - if err != nil { + accountID := parseAccountIDParam(c) + if accountID == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account_id"}) return } ctx := c.Request.Context() - channels, err := h.twChannelSvc.ListByAccount(ctx, uint(accountID)) + channels, err := h.twChannelSvc.ListByAccount(ctx, accountID) if err != nil { applogger.L().Errorf("Failed to list Twilio SMS channels: %v", err) c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to list Twilio SMS channels"}) return } - c.JSON(http.StatusOK, gin.H{"channels": channels}) + payload := make([]map[string]any, 0, len(channels)) + for i := range channels { + payload = append(payload, h.serializeInboxForTwilioChannel(c, &channels[i])) + } + c.JSON(http.StatusOK, gin.H{"payload": payload}) +} + +func (h *TwilioChannelHandler) serializeInboxForTwilioChannel(c *gin.Context, ch *channelmodel.ChannelTwilioSMS) map[string]any { + if ch != nil && ch.InboxID > 0 { + if inbox, err := h.inboxSvc.GetByAccountAndID(c.Request.Context(), ch.AccountID, ch.InboxID); err == nil { + return serializeInbox(inbox) + } + } + return gin.H{"id": ch.ID, "account_id": ch.AccountID, "inbox_id": ch.InboxID, "account_sid": ch.AccountSID, "phone_number": ch.PhoneNumber, "messaging_service_sid": ch.MessagingServiceSID} +} + +func twilioChannelConfigFromModel(ch *channelmodel.ChannelTwilioSMS) map[string]any { + medium := "sms" + if strings.HasPrefix(ch.PhoneNumber, "whatsapp:") { + medium = "whatsapp" + } + return map[string]any{ + "account_sid": ch.AccountSID, + "phone_number": ch.PhoneNumber, + "messaging_service_sid": ch.MessagingServiceSID, + "medium": medium, + } } diff --git a/internal/handler/api/v1/twilio_channel_handler_test.go b/internal/handler/api/v1/twilio_channel_handler_test.go index 7659d872..738e89c8 100644 --- a/internal/handler/api/v1/twilio_channel_handler_test.go +++ b/internal/handler/api/v1/twilio_channel_handler_test.go @@ -170,14 +170,20 @@ func TestTwilioChannel_Get_Success(t *testing.T) { router := setupTwilioTestRouter(handler) accountID := twilioAccountIDUint(db) - // Seed a channel + // Seed a channel and its associated inbox. ch := &channelmodel.ChannelTwilioSMS{ AccountSID: "ACget123", PhoneNumber: "+15559999999", AccountID: accountID, - InboxID: 200, } require.NoError(t, db.Create(ch).Error) + inbox := createTestChannelInbox(t, db, accountID, ch.ID, "Get SMS", "twilio_sms", map[string]any{ + "account_sid": "ACget123", + "phone_number": "+15559999999", + "medium": "sms", + }) + ch.InboxID = inbox.ID + require.NoError(t, db.Save(ch).Error) w := httptest.NewRecorder() req, _ := http.NewRequest(http.MethodGet, "/api/v1/accounts/"+strconv.FormatUint(uint64(accountID), 10)+"/inboxes/1/twilio_sms_channels/"+strconv.FormatUint(uint64(ch.ID), 10), nil) @@ -186,9 +192,10 @@ func TestTwilioChannel_Get_Success(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code) var resp map[string]interface{} require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) - channelData, ok := resp["channel"].(map[string]interface{}) - require.True(t, ok) - assert.Equal(t, "ACget123", channelData["account_sid"]) + assert.Equal(t, "Get SMS", resp["name"]) + assert.Equal(t, "Channel::TwilioSms", resp["channel_type"]) + assert.Equal(t, "ACget123", resp["account_sid"]) + require.NotContains(t, resp, "channel") } func TestTwilioChannel_Get_InvalidID(t *testing.T) { @@ -225,14 +232,20 @@ func TestTwilioChannel_Update_Success(t *testing.T) { router := setupTwilioTestRouter(handler) accountID := twilioAccountIDUint(db) - // Seed a channel + // Seed a channel and its associated inbox. ch := &channelmodel.ChannelTwilioSMS{ AccountSID: "ACupd123", PhoneNumber: "+15558888888", AccountID: accountID, - InboxID: 201, } require.NoError(t, db.Create(ch).Error) + inbox := createTestChannelInbox(t, db, accountID, ch.ID, "Update SMS", "twilio_sms", map[string]any{ + "account_sid": "ACupd123", + "phone_number": "+15558888888", + "medium": "sms", + }) + ch.InboxID = inbox.ID + require.NoError(t, db.Save(ch).Error) newPhone := "+15557777777" body := UpdateTwilioSMSChannelRequest{ @@ -360,15 +373,21 @@ func TestTwilioChannel_List_Success(t *testing.T) { router := setupTwilioTestRouter(handler) accountID := twilioAccountIDUint(db) - // Seed multiple channels + // Seed multiple channels and their associated inboxes. for i := 0; i < 3; i++ { ch := &channelmodel.ChannelTwilioSMS{ AccountSID: "AClist" + strconv.Itoa(i), PhoneNumber: "+1555" + strconv.Itoa(i) + "00000", AccountID: accountID, - InboxID: uint(i + 100), // unique inbox IDs to satisfy uniqueIndex constraint } require.NoError(t, db.Create(ch).Error) + inbox := createTestChannelInbox(t, db, accountID, ch.ID, "SMS List "+strconv.Itoa(i), "twilio_sms", map[string]any{ + "account_sid": ch.AccountSID, + "phone_number": ch.PhoneNumber, + "medium": "sms", + }) + ch.InboxID = inbox.ID + require.NoError(t, db.Save(ch).Error) } w := httptest.NewRecorder() @@ -378,7 +397,7 @@ func TestTwilioChannel_List_Success(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code) var resp map[string]interface{} require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) - channels, ok := resp["channels"].([]interface{}) + channels, ok := resp["payload"].([]interface{}) require.True(t, ok) assert.Len(t, channels, 3) } @@ -406,7 +425,7 @@ func TestTwilioChannel_List_Empty(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code) var resp map[string]interface{} require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) - channels, ok := resp["channels"].([]interface{}) + channels, ok := resp["payload"].([]interface{}) require.True(t, ok) assert.Len(t, channels, 0) } diff --git a/internal/service/inbox_service.go b/internal/service/inbox_service.go index 07481f1d..c0f3f85d 100644 --- a/internal/service/inbox_service.go +++ b/internal/service/inbox_service.go @@ -234,6 +234,29 @@ func (s *InboxService) Update(ctx context.Context, accountID, id uint, req Updat return inbox, nil } +// BindChannel persists the channel id and channel_config for channel-specific +// controllers that still create their dedicated channel record first. +func (s *InboxService) BindChannel(ctx context.Context, accountID, id, channelID uint, channel map[string]any) (*model.Inbox, error) { + inbox, err := s.repo.FindByAccountAndID(ctx, accountID, id) + if err != nil { + return nil, err + } + inbox.ChannelID = channelID + if len(channel) > 0 { + config := parseChannelConfigMap(inbox.ChannelConfig) + mergeInboxChannelConfig(config, channel) + configJSON, err := json.Marshal(config) + if err != nil { + return nil, err + } + inbox.ChannelConfig = string(configJSON) + } + if err := s.repo.Update(ctx, inbox); err != nil { + return nil, err + } + return inbox, nil +} + func applyCreateInboxSettings(inbox *model.Inbox, req CreateInboxRequest) { if req.GreetingEnabled != nil { inbox.GreetingEnabled = *req.GreetingEnabled