Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
Restructure the monorepo into clear top-level directories: - backend/: Go module root (cmd, internal, pkg, configs, migrations, docs/swagger, scripts, tests, go.mod, Makefile, .air.toml) - deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd - docs/: project documentation + reports/ (moved from repo root) - AGENTS.md: new AI coding-agent guide at repo root Update all references to the new layout: - Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root) - docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile, env_file ../../.env, volume mounts ../../backend:/app - deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile - CI: working-directory: backend for go commands, file deploy/docker/Dockerfile, coverage path backend/coverage.out, health_check backend/scripts/ - backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../ - README: architecture tree, quickstart, config paths updated Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh, gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to preserve history. Build, vet, SQLite tests, and docker compose config verified.
This commit is contained in:
@@ -0,0 +1,398 @@
|
||||
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"
|
||||
|
||||
"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
|
||||
}
|
||||
|
||||
redirectURL, err := buildTikTokChatwootAuthorizationURL(accountID, authorizationReturnTo(c))
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Failed to build TikTok authorization URL: %v", err)
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"success": false})
|
||||
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),
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user