Files
gochat/backend/internal/handler/api/v1/tiktok_channel_handler.go
T

412 lines
14 KiB
Go

package v1
// TikTokChannelHandler handles TikTok Business channel-specific configuration CRUD.
// Reference: This is a GoChat addition — Chatwoot does not have TikTok channel support.
//
// TikTok Business API: https://business-api.tiktok.com/portal/docs
//
// gochat maps TikTok channel operations to:
// - POST /api/v1/accounts/:id/channels/tiktok_channel → create TikTok inbox
// - GET /api/v1/accounts/:id/channels/tiktok_channel/:tt_id → get TikTok channel
// - PATCH /api/v1/accounts/:id/channels/tiktok_channel/:tt_id → update TikTok channel
// - DELETE /api/v1/accounts/:id/channels/tiktok_channel/:tt_id → delete TikTok channel
// - GET /api/v1/accounts/:id/channels/tiktok_channel → list TikTok channels
import (
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
tiktokchannel "github.com/gochat/gochat/internal/channel/tiktok"
channelmodel "github.com/gochat/gochat/internal/model/channel"
"github.com/gochat/gochat/internal/repository"
"github.com/gochat/gochat/internal/service"
applogger "github.com/gochat/gochat/pkg/logger"
"github.com/gochat/gochat/pkg/response"
)
// TikTokChannelHandler handles TikTok Business channel management.
// Follows FacebookChannelHandler pattern: uses top-level ChannelTikTokService for CRUD,
// internal TikTokProvider for webhook setup via OnCreate.
type TikTokChannelHandler struct {
ttChannelSvc *service.ChannelTikTokService
ttProvider *tiktokchannel.TikTokProvider
inboxSvc *service.InboxService
ttRepo *repository.ChannelTikTokRepo
}
// NewTikTokChannelHandler creates a new TikTok channel handler.
func NewTikTokChannelHandler(
ttChannelSvc *service.ChannelTikTokService,
ttProvider *tiktokchannel.TikTokProvider,
inboxSvc *service.InboxService,
ttRepo *repository.ChannelTikTokRepo,
) *TikTokChannelHandler {
return &TikTokChannelHandler{
ttChannelSvc: ttChannelSvc,
ttProvider: ttProvider,
inboxSvc: inboxSvc,
ttRepo: ttRepo,
}
}
// === TikTok Channel CRUD ===
// ChatwootAuthorization creates a TikTok OAuth authorization URL.
// POST /api/v1/accounts/:account_id/tiktok/authorization
func (h *TikTokChannelHandler) ChatwootAuthorization(c *gin.Context) {
accountID, err := parseUintParam(c, "account_id")
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return
}
var req TikTokAuthorizationRequest
_ = c.ShouldBindJSON(&req)
appID := req.AppID
if strings.TrimSpace(appID) == "" {
appID = envOrDefaultV1("TIKTOK_APP_ID", "")
}
appSecret := req.AppSecret
if strings.TrimSpace(appSecret) == "" {
appSecret = envOrDefaultV1("TIKTOK_APP_SECRET", "")
}
redirectURL, err := buildTikTokChatwootAuthorizationURL(accountID, authorizationReturnTo(c, req.ReturnTo), appID, appSecret)
if err != nil {
applogger.L().Errorf("Failed to build TikTok authorization URL: %v", err)
c.JSON(http.StatusUnprocessableEntity, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "url": redirectURL})
}
// CreateTikTokChannelRequest is the DTO for creating a TikTok Business channel inbox.
type CreateTikTokChannelRequest struct {
Name string `json:"name" validate:"required,min=2"`
TikTokBusinessID string `json:"tiktok_business_id" validate:"required"`
AccessToken string `json:"access_token" validate:"required"`
InboxName string `json:"inbox_name,omitempty"`
EnableAutoAssignment bool `json:"enable_auto_assignment,omitempty"`
}
// CreateTikTokChannel creates a new TikTok Business channel and its associated inbox.
// POST /api/v1/accounts/:id/channels/tiktok_channel
func (h *TikTokChannelHandler) CreateTikTokChannel(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)
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account_id"})
return
}
var req CreateTikTokChannelRequest
if err := c.ShouldBindJSON(&req); err != nil {
applogger.L().Errorf("Failed to bind create tiktok channel request: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
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),
TikTokBusinessID: req.TikTokBusinessID,
AccessToken: req.AccessToken,
}
if err := h.ttChannelSvc.Create(ctx, channelRecord); err != nil {
applogger.L().Errorf("Failed to create TikTok channel: %v", err)
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to create TikTok channel", "details": err.Error()})
return
}
// 2. Create Inbox record with channel_type=tiktok
inboxName := req.InboxName
if inboxName == "" {
inboxName = req.Name
}
createdInbox, err := h.inboxSvc.Create(ctx, uint(accountID), service.CreateInboxRequest{
Name: inboxName,
ChannelType: "tiktok",
Channel: map[string]any{
"tiktok_business_id": req.TikTokBusinessID,
"access_token": req.AccessToken,
},
EnableAutoAssignment: req.EnableAutoAssignment,
})
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
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)
// Non-critical: inbox was created, channel link can be repaired
}
// 4. Call ttProvider.OnCreate for webhook setup
// Note: OnCreate expects a ChannelConfig map; we build one from request data.
ttConfig := map[string]interface{}{
"tiktok_business_id": req.TikTokBusinessID,
"access_token": req.AccessToken,
}
if h.ttProvider == nil {
applogger.L().Warnf("TikTok OnCreate webhook setup skipped: provider is not configured")
} else if updatedConfig, onCreateErr := h.ttProvider.OnCreate(ctx, createdInbox, ttConfig); onCreateErr != nil {
applogger.L().Warnf("TikTok OnCreate webhook setup failed: %v", onCreateErr)
// Non-critical: channel + inbox created, webhook can be set up later
} else {
// Persist updated config (e.g. webhook_verify_token) if provider returned one
if verifyToken, ok := updatedConfig["webhook_verify_token"]; ok {
channelRecord.WebhookVerifyToken = verifyToken.(string)
if err := h.ttChannelSvc.Update(ctx, channelRecord); err != nil {
applogger.L().Warnf("Failed to persist TikTok webhook_verify_token: %v", err)
}
}
}
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,
},
"inbox": gin.H{
"id": createdInbox.ID,
"name": createdInbox.Name,
"channel_type": createdInbox.ChannelType,
"channel_id": createdInbox.ChannelID,
"enabled": createdInbox.Enabled,
"enable_auto_assignment": createdInbox.EnableAutoAssignment,
},
})
}
// GetTikTokChannel retrieves a TikTok channel by ID.
// GET /api/v1/accounts/:id/channels/tiktok_channel/:tt_id
func (h *TikTokChannelHandler) GetTikTokChannel(c *gin.Context) {
accountIDStr := c.Param("id")
accountID, err := strconv.ParseUint(accountIDStr, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account_id"})
return
}
ttIDStr := c.Param("tt_id")
ttID, err := strconv.ParseUint(ttIDStr, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid tt_id"})
return
}
ctx := c.Request.Context()
ch, err := h.ttChannelSvc.GetByID(ctx, uint(ttID))
if err != nil {
applogger.L().Errorf("Failed to get TikTok channel: %v", err)
c.JSON(http.StatusNotFound, gin.H{"error": "TikTok channel not found"})
return
}
// Verify account ownership
if ch.AccountID != uint(accountID) {
c.JSON(http.StatusForbidden, gin.H{"error": "TikTok channel does not belong to this account"})
return
}
c.JSON(http.StatusOK, gin.H{
"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"`
}
// UpdateTikTokChannel updates a TikTok channel configuration.
// PATCH /api/v1/accounts/:id/channels/tiktok_channel/:tt_id
func (h *TikTokChannelHandler) UpdateTikTokChannel(c *gin.Context) {
accountIDStr := c.Param("id")
accountID, err := strconv.ParseUint(accountIDStr, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account_id"})
return
}
ttIDStr := c.Param("tt_id")
ttID, err := strconv.ParseUint(ttIDStr, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid tt_id"})
return
}
var req UpdateTikTokChannelRequest
if err := c.ShouldBindJSON(&req); err != nil {
applogger.L().Errorf("Failed to bind update tiktok channel request: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx := c.Request.Context()
ch, err := h.ttChannelSvc.GetByID(ctx, uint(ttID))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "TikTok channel not found"})
return
}
// Verify account ownership
if ch.AccountID != uint(accountID) {
c.JSON(http.StatusForbidden, gin.H{"error": "TikTok channel does not belong to this account"})
return
}
// Apply updates
if req.AccessToken != "" {
ch.AccessToken = req.AccessToken
}
if err := h.ttChannelSvc.Update(ctx, ch); err != nil {
applogger.L().Errorf("Failed to update TikTok channel: %v", err)
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to update TikTok channel"})
return
}
// Update inbox name if provided
if req.InboxName != "" && ch.InboxID > 0 {
updateReq := service.UpdateInboxRequest{
Name: req.InboxName,
}
if req.EnableAutoAssignment != nil {
updateReq.EnableAutoAssignment = req.EnableAutoAssignment
}
if _, inboxErr := h.inboxSvc.Update(ctx, uint(accountID), ch.InboxID, updateReq); inboxErr != nil {
applogger.L().Warnf("Failed to update TikTok inbox: %v", inboxErr)
}
}
c.JSON(http.StatusOK, gin.H{
"id": ch.ID,
"account_id": ch.AccountID,
"inbox_id": ch.InboxID,
"tiktok_business_id": ch.TikTokBusinessID,
"message": "TikTok channel updated successfully",
})
}
// DeleteTikTokChannel removes a TikTok channel and its associated inbox.
// DELETE /api/v1/accounts/:id/channels/tiktok_channel/:tt_id
func (h *TikTokChannelHandler) DeleteTikTokChannel(c *gin.Context) {
accountIDStr := c.Param("id")
accountID, err := strconv.ParseUint(accountIDStr, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account_id"})
return
}
ttIDStr := c.Param("tt_id")
ttID, err := strconv.ParseUint(ttIDStr, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid tt_id"})
return
}
ctx := c.Request.Context()
ch, err := h.ttChannelSvc.GetByID(ctx, uint(ttID))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "TikTok channel not found"})
return
}
// Verify account ownership
if ch.AccountID != uint(accountID) {
c.JSON(http.StatusForbidden, gin.H{"error": "TikTok channel does not belong to this account"})
return
}
// Delete the channel record
if err := h.ttChannelSvc.Delete(ctx, uint(ttID)); err != nil {
applogger.L().Errorf("Failed to delete TikTok channel: %v", err)
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to delete TikTok channel"})
return
}
// Delete the associated inbox (if exists)
if ch.InboxID > 0 {
if delErr := h.inboxSvc.DeleteByAccount(ctx, uint(accountID), ch.InboxID); delErr != nil {
applogger.L().Warnf("Failed to delete inbox for TikTok channel: %v", delErr)
}
}
c.JSON(http.StatusOK, gin.H{"message": "TikTok channel deleted successfully"})
}
// ListTikTokChannels retrieves all TikTok channels for an account.
// GET /api/v1/accounts/:id/channels/tiktok_channel
func (h *TikTokChannelHandler) ListTikTokChannels(c *gin.Context) {
accountIDStr := c.Param("id")
accountID, err := strconv.ParseUint(accountIDStr, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account_id"})
return
}
ctx := c.Request.Context()
channels, err := h.ttChannelSvc.ListByAccount(ctx, uint(accountID))
if err != nil {
applogger.L().Errorf("Failed to list TikTok channels: %v", err)
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to list TikTok channels"})
return
}
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,
"reauthorization_required": ch.ReauthorizationRequired,
})
}
c.JSON(http.StatusOK, gin.H{
"channels": result,
"count": len(result),
})
}