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:
2026-07-07 14:44:12 +08:00
parent d4ef996f49
commit aeddedf2a3
1348 changed files with 176 additions and 57 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,172 @@
package widget
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/service"
applogger "github.com/gochat/gochat/pkg/logger"
)
// --- Widget Theme Handlers (Public-facing, website_token path param) ---
// M11: Extended theme configuration beyond widget_color
// GetThemeConfig returns the custom theme configuration for a widget.
// GET /widget/:website_token/theme_config
// No widget_token required — theme is public config for the widget SDK to render.
func (h *WidgetHandler) GetThemeConfig(c *gin.Context) {
websiteToken := c.Param("website_token")
if websiteToken == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "website_token is required"})
return
}
themeConfig, err := h.widgetService.GetThemeConfig(c.Request.Context(), websiteToken)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if themeConfig == nil {
// No custom theme — return empty so SDK falls back to defaults
c.JSON(http.StatusOK, gin.H{"theme": nil})
return
}
c.JSON(http.StatusOK, gin.H{"theme": themeConfig})
}
// --- Widget Pre-Chat Form Handlers (Public-facing, website_token path param) ---
// M11: Structured pre-chat form before starting conversation
// GetPreChatForm returns the pre-chat form definition for a widget.
// GET /widget/:website_token/pre_chat_form
// No widget_token required — form definition is public config before visitor authenticates.
func (h *WidgetHandler) GetPreChatForm(c *gin.Context) {
websiteToken := c.Param("website_token")
if websiteToken == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "website_token is required"})
return
}
form, err := h.widgetService.GetPreChatForm(c.Request.Context(), websiteToken)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if form == nil {
// No pre-chat form — return empty so SDK skips form step
c.JSON(http.StatusOK, gin.H{"pre_chat_form": nil})
return
}
c.JSON(http.StatusOK, gin.H{"pre_chat_form": form})
}
// SubmitPreChatForm processes a visitor's pre-chat form submission.
// POST /widget/:website_token/pre_chat_form
// This creates/identifies the contact and returns a widget_token for subsequent requests.
// Reference: Chatwoot widget SDK — pre-chat form submission flow
func (h *WidgetHandler) SubmitPreChatForm(c *gin.Context) {
websiteToken := c.Param("website_token")
if websiteToken == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "website_token is required"})
return
}
var submission model.PreChatFormSubmission
if err := c.ShouldBindJSON(&submission); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()})
return
}
resp, err := h.widgetService.SubmitPreChatForm(c.Request.Context(), websiteToken, submission)
if err != nil {
status := http.StatusBadRequest
if err.Error() == "pre-chat form is not enabled for this inbox" {
status = http.StatusForbidden
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, resp)
}
// --- Widget File Upload Handlers (website_token path param) ---
// M11: Staged file upload with attachment processing
// StageFileUpload handles a file upload from the widget, staging it for later attachment.
// POST /widget/:website_token/uploads
// Accepts multipart/form-data with a "file" field.
// Returns upload_uuid that can be referenced when sending a message with attachment.
// Reference: Chatwoot widget SDK — file upload before sending message
func (h *WidgetHandler) StageFileUpload(c *gin.Context) {
websiteToken := c.Param("website_token")
if websiteToken == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "website_token is required"})
return
}
file, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "file field is required", "details": err.Error()})
return
}
fileReader, err := file.Open()
if err != nil {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to open uploaded file", "details": err.Error()})
return
}
defer fileReader.Close()
req := service.WidgetUploadRequest{
WebsiteToken: websiteToken,
FileName: file.Filename,
FileSize: file.Size,
FileHeader: file,
}
resp, err := h.widgetService.StageFileUpload(c.Request.Context(), req, fileReader)
if err != nil {
applogger.L().Errorf("Failed to stage file upload for website_token=%s: %v", websiteToken, err)
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, resp)
}
// GetFileUploadStatus checks the status of a staged file upload by UUID.
// GET /widget/:website_token/uploads/:upload_uuid
func (h *WidgetHandler) GetFileUploadStatus(c *gin.Context) {
websiteToken := c.Param("website_token")
if websiteToken == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "website_token is required"})
return
}
uploadUUID := c.Param("upload_uuid")
if uploadUUID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "upload_uuid is required"})
return
}
resp, err := h.widgetService.GetFileUploadStatus(c.Request.Context(), websiteToken, uploadUUID)
if err != nil {
applogger.L().Errorf("Failed to get upload status for uuid=%s: %v", uploadUUID, err)
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if resp == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "upload not found"})
return
}
c.JSON(http.StatusOK, resp)
}
@@ -0,0 +1,370 @@
package widget
import (
"bytes"
"context"
"mime/multipart"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/repository"
"github.com/gochat/gochat/internal/service"
ws "github.com/gochat/gochat/internal/ws"
)
// noopTypingIndicatorThemePublic is a stub that satisfies service.TypingIndicator
type noopTypingIndicatorThemePublic struct{}
func (n *noopTypingIndicatorThemePublic) SetTypingOn(_ context.Context, _ uint, _ uint, _ *ws.Performer) error {
return nil
}
func (n *noopTypingIndicatorThemePublic) SetTypingOff(_ context.Context, _ uint, _ uint, _ *ws.Performer) error {
return nil
}
type WidgetThemeHandlerTestSuite struct {
suite.Suite
router *gin.Engine
handler *WidgetHandler
db *gorm.DB
}
func (s *WidgetThemeHandlerTestSuite) SetupSuite() {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
s.Require().NoError(err)
s.db = db
err = db.AutoMigrate(
&model.Account{},
&model.User{},
&model.Inbox{},
&model.Contact{},
&model.ContactInbox{},
&model.Conversation{},
&model.Message{},
&model.WidgetThemeConfig{},
&model.PreChatForm{},
&model.WidgetFileUpload{},
&model.WidgetOfflineMessage{},
)
s.Require().NoError(err)
// Create real repos
inboxRepo := repository.NewInboxRepo(db)
contactRepo := repository.NewContactRepo(db)
contactInboxRepo := repository.NewContactInboxRepo(db)
conversationRepo := repository.NewConversationRepo(db)
messageRepo := repository.NewMessageRepo(db)
themeConfigRepo := repository.NewWidgetThemeConfigRepo(db)
preChatFormRepo := repository.NewPreChatFormRepo(db)
fileUploadRepo := repository.NewWidgetFileUploadRepo(db)
offlineMessageRepo := repository.NewWidgetOfflineMessageRepo(db)
widgetSvc := service.NewWidgetService(
inboxRepo,
contactRepo,
contactInboxRepo,
conversationRepo,
messageRepo,
&noopTypingIndicatorThemePublic{},
themeConfigRepo,
preChatFormRepo,
fileUploadRepo,
offlineMessageRepo,
nil,
nil,
nil,
)
s.handler = NewHandler(widgetSvc)
s.router = gin.New()
widgetGroup := s.router.Group("/widget/:website_token")
widgetGroup.GET("/theme_config", s.handler.GetThemeConfig)
widgetGroup.GET("/pre_chat_form", s.handler.GetPreChatForm)
widgetGroup.POST("/pre_chat_form", s.handler.SubmitPreChatForm)
widgetGroup.POST("/uploads", s.handler.StageFileUpload)
widgetGroup.GET("/uploads/:upload_uuid", s.handler.GetFileUploadStatus)
}
func (s *WidgetThemeHandlerTestSuite) TearDownSuite() {
sqlDB, _ := s.db.DB()
sqlDB.Close()
}
func (s *WidgetThemeHandlerTestSuite) seedAccountAndWidgetInbox() (*model.Account, *model.Inbox) {
acc := &model.Account{Name: "ThemePubTestOrg"}
s.Require().NoError(s.db.Create(acc).Error)
inbox := &model.Inbox{
AccountID: acc.ID,
Name: "Widget Inbox",
ChannelType: "web_widget",
Enabled: true,
}
s.Require().NoError(s.db.Create(inbox).Error)
return acc, inbox
}
// --- Theme Config Tests (Public-facing) ---
func (s *WidgetThemeHandlerTestSuite) TestGetThemeConfig_MissingWebsiteToken() {
// Request with empty website_token — handler receives "" param and returns 400
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/widget//theme_config", nil)
s.router.ServeHTTP(w, req)
// Gin may route this with empty website_token; handler returns 400 for empty token
// OR Gin may not match the route and return 404
assert.True(s.T(), w.Code == http.StatusBadRequest || w.Code == http.StatusNotFound)
}
func (s *WidgetThemeHandlerTestSuite) TestGetThemeConfig_InvalidWebsiteToken() {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/widget/nonexistent_token/theme_config", nil)
s.router.ServeHTTP(w, req)
// Should return 400 because no inbox has that website_token
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
}
func (s *WidgetThemeHandlerTestSuite) TestGetThemeConfig_Success_NoConfig() {
_, inbox := s.seedAccountAndWidgetInbox()
// Use inbox ID as a fake website_token since real tokens come from ChannelWebWidget config
w := httptest.NewRecorder()
url := "/widget/" + strconv.FormatUint(uint64(inbox.ID), 10) + "/theme_config"
req, _ := http.NewRequest("GET", url, nil)
s.router.ServeHTTP(w, req)
// May return 200 (nil theme) or 400 (token not found) depending on how
// GetThemeConfig resolves website_token → inbox. Both are valid outcomes.
assert.True(s.T(), w.Code == http.StatusOK || w.Code == http.StatusBadRequest)
}
func (s *WidgetThemeHandlerTestSuite) TestGetThemeConfig_Success_WithConfig() {
_, inbox := s.seedAccountAndWidgetInbox()
// Seed a theme config
theme := &model.WidgetThemeConfig{
InboxID: inbox.ID,
PrimaryColor: "#3366ff",
BackgroundColor: "#ffffff",
}
s.Require().NoError(s.db.Create(theme).Error)
w := httptest.NewRecorder()
url := "/widget/" + strconv.FormatUint(uint64(inbox.ID), 10) + "/theme_config"
req, _ := http.NewRequest("GET", url, nil)
s.router.ServeHTTP(w, req)
// Outcome depends on whether the service can resolve the website_token to the inbox
assert.True(s.T(), w.Code == http.StatusOK || w.Code == http.StatusBadRequest)
}
// --- Pre-Chat Form Tests (Public-facing) ---
func (s *WidgetThemeHandlerTestSuite) TestGetPreChatForm_MissingWebsiteToken() {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/widget//pre_chat_form", nil)
s.router.ServeHTTP(w, req)
// Gin may route with empty website_token; handler returns 400 for empty token
// OR Gin may not match the route and return 404
assert.True(s.T(), w.Code == http.StatusBadRequest || w.Code == http.StatusNotFound)
}
func (s *WidgetThemeHandlerTestSuite) TestGetPreChatForm_InvalidWebsiteToken() {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/widget/nonexistent_token/pre_chat_form", nil)
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
}
func (s *WidgetThemeHandlerTestSuite) TestSubmitPreChatForm_InvalidBody() {
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/widget/nonexistent_token/pre_chat_form", strings.NewReader("not json"))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
}
func (s *WidgetThemeHandlerTestSuite) TestSubmitPreChatForm_InvalidWebsiteToken() {
body := `{"name": "Visitor", "email": "visitor@test.com"}`
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/widget/nonexistent_token/pre_chat_form", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
// Should return 400 (invalid token)
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
}
func (s *WidgetThemeHandlerTestSuite) TestSubmitPreChatForm_EmptySubmission() {
// Submit with empty fields — service should still process (or error if no form configured)
body := `{}`
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/widget/nonexistent_token/pre_chat_form", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
// Should return 400 (invalid website_token) or 403 (no pre-chat form enabled)
assert.True(s.T(), w.Code == http.StatusBadRequest || w.Code == http.StatusForbidden)
}
func (s *WidgetThemeHandlerTestSuite) TestGetPreChatForm_Success() {
_, inbox := s.seedAccountAndWidgetInbox()
// Set channel_config with a website_token so the service can resolve the inbox
channelConfig := `{"website_token":"test_prechat_token","widget_color":"#3366ff"}`
s.Require().NoError(s.db.Model(inbox).Update("channel_config", channelConfig).Error)
// Seed a pre-chat form for this inbox
form := &model.PreChatForm{
InboxID: inbox.ID,
Enabled: true,
RequireName: true,
RequireEmail: true,
RequirePhone: false,
ShowCompany: true,
ShowCity: false,
ShowCountry: false,
}
s.Require().NoError(s.db.Create(form).Error)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/widget/test_prechat_token/pre_chat_form", nil)
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
}
func (s *WidgetThemeHandlerTestSuite) TestGetPreChatForm_Success_NoForm() {
_, inbox := s.seedAccountAndWidgetInbox()
// Set channel_config with a website_token
channelConfig := `{"website_token":"test_prechat_noform_token","widget_color":"#3366ff"}`
s.Require().NoError(s.db.Model(inbox).Update("channel_config", channelConfig).Error)
// No pre-chat form seeded — should return 404 or empty response
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/widget/test_prechat_noform_token/pre_chat_form", nil)
s.router.ServeHTTP(w, req)
// No pre-chat form seeded — handler returns 200 with null form (SDK skips form step)
assert.Equal(s.T(), http.StatusOK, w.Code)
}
func (s *WidgetThemeHandlerTestSuite) TestSubmitPreChatForm_Success() {
_, inbox := s.seedAccountAndWidgetInbox()
// Set channel_config with a website_token
channelConfig := `{"website_token":"test_submit_prechat_token","widget_color":"#3366ff"}`
s.Require().NoError(s.db.Model(inbox).Update("channel_config", channelConfig).Error)
// Seed a pre-chat form so submission can be validated
form := &model.PreChatForm{
InboxID: inbox.ID,
Enabled: true,
RequireName: true,
RequireEmail: true,
RequirePhone: false,
ShowCompany: false,
ShowCity: false,
ShowCountry: false,
}
s.Require().NoError(s.db.Create(form).Error)
body := `{"name":"Test Visitor","email":"visitor@example.com","message":"Hello!"}`
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/widget/test_submit_prechat_token/pre_chat_form", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
// Success should return 200 or 201
assert.True(s.T(), w.Code == http.StatusOK || w.Code == http.StatusCreated)
}
// --- File Upload Tests (Public-facing) ---
func (s *WidgetThemeHandlerTestSuite) TestStageFileUpload_NoFileField() {
w := httptest.NewRecorder()
// Send multipart form without a "file" field
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
writer.WriteField("website_token", "test_token")
writer.Close()
req, _ := http.NewRequest("POST", "/widget/test_token/uploads", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
s.router.ServeHTTP(w, req)
// Should return 400 because "file" field is missing
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
}
func (s *WidgetThemeHandlerTestSuite) TestStageFileUpload_InvalidWebsiteToken() {
w := httptest.NewRecorder()
// Create multipart form with a small file
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("file", "test.txt")
assert.NoError(s.T(), err)
part.Write([]byte("hello world"))
writer.Close()
req, _ := http.NewRequest("POST", "/widget/nonexistent_token/uploads", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
s.router.ServeHTTP(w, req)
// Should return 400 (invalid website_token)
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
}
func (s *WidgetThemeHandlerTestSuite) TestGetFileUploadStatus_MissingUploadUUID() {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/widget/test_token/uploads/", nil)
s.router.ServeHTTP(w, req)
// Route won't match without upload_uuid — 404
assert.Equal(s.T(), http.StatusNotFound, w.Code)
}
func (s *WidgetThemeHandlerTestSuite) TestGetFileUploadStatus_InvalidWebsiteToken() {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/widget/nonexistent_token/uploads/some-uuid", nil)
s.router.ServeHTTP(w, req)
// Should return 400 (invalid website_token) or 404 (upload not found)
assert.True(s.T(), w.Code == http.StatusBadRequest || w.Code == http.StatusNotFound)
}
func (s *WidgetThemeHandlerTestSuite) TestGetFileUploadStatus_InvalidUploadUUID() {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/widget/test_token/uploads/invalid-uuid", nil)
s.router.ServeHTTP(w, req)
// Should return 400 (invalid website_token) or 404 (upload not found)
assert.True(s.T(), w.Code == http.StatusBadRequest || w.Code == http.StatusNotFound)
}
func TestWidgetThemeHandlerTestSuite(t *testing.T) {
suite.Run(t, new(WidgetThemeHandlerTestSuite))
}