Files
gochat/backend/internal/service/widget_theme_service.go
T
rogee aeddedf2a3 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.
2026-07-07 14:44:12 +08:00

632 lines
21 KiB
Go

package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
"github.com/gochat/gochat/internal/model"
applogger "github.com/gochat/gochat/pkg/logger"
)
// --- Widget Theme Service Logic ---
// GetThemeConfigByInboxID retrieves the theme config for an inbox by ID (admin endpoint).
func (s *WidgetService) GetThemeConfigByInboxID(ctx context.Context, inboxID uint) (*model.WidgetThemeConfig, error) {
if inboxID == 0 {
return nil, errors.New("inbox_id is required")
}
themeConfig, err := s.themeConfigRepo.FindByInboxID(ctx, inboxID)
if err != nil {
return nil, nil
}
return themeConfig, nil
}
// GetThemeConfig retrieves the theme config for an inbox identified by website_token.
// If no custom theme exists, returns nil (widget SDK will use defaults from WebWidgetConfig).
func (s *WidgetService) GetThemeConfig(ctx context.Context, websiteToken string) (*model.WidgetThemeConfig, error) {
if websiteToken == "" {
return nil, errors.New("website_token is required")
}
inbox, err := s.findInboxByWebsiteToken(ctx, websiteToken)
if err != nil {
return nil, fmt.Errorf("invalid website_token: %w", err)
}
themeConfig, err := s.themeConfigRepo.FindByInboxID(ctx, inbox.ID)
if err != nil {
// No custom theme configured — not an error, just return nil
return nil, nil
}
return themeConfig, nil
}
// UpdateThemeConfig creates or updates the theme config for an inbox.
// Admin-only operation (called via v1 handler with account scope).
func (s *WidgetService) UpdateThemeConfig(ctx context.Context, inboxID uint, config *model.WidgetThemeConfig) (*model.WidgetThemeConfig, error) {
if inboxID == 0 {
return nil, errors.New("inbox_id is required")
}
// Verify inbox exists and is web_widget type
inbox, err := s.inboxRepo.FindByID(ctx, inboxID)
if err != nil {
return nil, fmt.Errorf("inbox not found: %w", err)
}
if inbox.ChannelType != "web_widget" {
return nil, errors.New("theme config only supported for web_widget channel")
}
config.InboxID = inboxID
existing, err := s.themeConfigRepo.FindByInboxID(ctx, inboxID)
if err != nil {
// Create new
if err := s.themeConfigRepo.Create(ctx, config); err != nil {
return nil, fmt.Errorf("failed to create theme config: %w", err)
}
applogger.L().Infof("Created theme config for inbox=%d", inboxID)
return config, nil
}
// Update existing — copy ID for GORM Save
config.ID = existing.ID
config.CreatedAt = existing.CreatedAt
if err := s.themeConfigRepo.Update(ctx, config); err != nil {
return nil, fmt.Errorf("failed to update theme config: %w", err)
}
applogger.L().Infof("Updated theme config for inbox=%d", inboxID)
return config, nil
}
// DeleteThemeConfig removes the custom theme configuration for a web_widget inbox.
func (s *WidgetService) DeleteThemeConfig(ctx context.Context, inboxID uint) error {
if inboxID == 0 {
return errors.New("inbox_id is required")
}
// Verify inbox exists and is web_widget type
inbox, err := s.inboxRepo.FindByID(ctx, inboxID)
if err != nil {
return fmt.Errorf("inbox not found: %w", err)
}
if inbox.ChannelType != "web_widget" {
return errors.New("theme config only supported for web_widget channel")
}
if err := s.themeConfigRepo.DeleteByInboxID(ctx, inboxID); err != nil {
return fmt.Errorf("failed to delete theme config: %w", err)
}
applogger.L().Infof("Deleted theme config for inbox=%d", inboxID)
return nil
}
// --- Widget Pre-Chat Form Service Logic ---
// GetPreChatFormByInboxID retrieves the pre-chat form for an inbox by ID (admin endpoint).
func (s *WidgetService) GetPreChatFormByInboxID(ctx context.Context, inboxID uint) (*model.PreChatForm, error) {
if inboxID == 0 {
return nil, errors.New("inbox_id is required")
}
form, err := s.preChatFormRepo.FindByInboxID(ctx, inboxID)
if err != nil {
return nil, nil
}
return form, nil
}
// GetPreChatForm retrieves the pre-chat form configuration for an inbox by website_token.
// Returns the form definition so the widget SDK can render it before starting a conversation.
func (s *WidgetService) GetPreChatForm(ctx context.Context, websiteToken string) (*model.PreChatForm, error) {
if websiteToken == "" {
return nil, errors.New("website_token is required")
}
inbox, err := s.findInboxByWebsiteToken(ctx, websiteToken)
if err != nil {
return nil, fmt.Errorf("invalid website_token: %w", err)
}
form, err := s.preChatFormRepo.FindByInboxID(ctx, inbox.ID)
if err != nil {
// No pre-chat form configured — return a default based on WebWidgetConfig
widgetConfig, cfgErr := ParseWebWidgetConfig(inbox.ChannelConfig)
if cfgErr != nil {
return nil, nil
}
if !widgetConfig.PreChatFieldsEnabled {
return nil, nil
}
// Build a minimal default form from channel_config settings
return &model.PreChatForm{
InboxID: inbox.ID,
Enabled: widgetConfig.PreChatFieldsEnabled,
Message: widgetConfig.PreChatMessage,
RequireName: true,
RequireEmail: true,
}, nil
}
return form, nil
}
// UpdatePreChatForm creates or updates the pre-chat form configuration for an inbox.
// Admin-only operation (called via v1 handler with account scope).
func (s *WidgetService) UpdatePreChatForm(ctx context.Context, inboxID uint, form *model.PreChatForm) (*model.PreChatForm, error) {
if inboxID == 0 {
return nil, errors.New("inbox_id is required")
}
// Verify inbox exists and is web_widget type
inbox, err := s.inboxRepo.FindByID(ctx, inboxID)
if err != nil {
return nil, fmt.Errorf("inbox not found: %w", err)
}
if inbox.ChannelType != "web_widget" {
return nil, errors.New("pre-chat form only supported for web_widget channel")
}
form.InboxID = inboxID
// Validate custom fields JSON if present
if form.CustomFields != nil {
var fields []model.PreChatCustomField
if err := json.Unmarshal(form.CustomFields, &fields); err != nil {
return nil, fmt.Errorf("invalid custom_fields JSON: %w", err)
}
for _, f := range fields {
if f.Name == "" {
return nil, errors.New("custom field name is required")
}
if f.Type != "text" && f.Type != "email" && f.Type != "phone" &&
f.Type != "select" && f.Type != "checkbox" && f.Type != "date" {
return nil, fmt.Errorf("invalid custom field type: %s", f.Type)
}
}
}
existing, err := s.preChatFormRepo.FindByInboxID(ctx, inboxID)
if err != nil {
// Create new
if err := s.preChatFormRepo.Create(ctx, form); err != nil {
return nil, fmt.Errorf("failed to create pre-chat form: %w", err)
}
applogger.L().Infof("Created pre-chat form for inbox=%d", inboxID)
return form, nil
}
// Update existing
form.ID = existing.ID
form.CreatedAt = existing.CreatedAt
if err := s.preChatFormRepo.Update(ctx, form); err != nil {
return nil, fmt.Errorf("failed to update pre-chat form: %w", err)
}
applogger.L().Infof("Updated pre-chat form for inbox=%d", inboxID)
return form, nil
}
// DeletePreChatForm removes the pre-chat form configuration for a web_widget inbox.
func (s *WidgetService) DeletePreChatForm(ctx context.Context, inboxID uint) error {
if inboxID == 0 {
return errors.New("inbox_id is required")
}
// Verify inbox exists and is web_widget type
inbox, err := s.inboxRepo.FindByID(ctx, inboxID)
if err != nil {
return fmt.Errorf("inbox not found: %w", err)
}
if inbox.ChannelType != "web_widget" {
return errors.New("pre-chat form only supported for web_widget channel")
}
if err := s.preChatFormRepo.DeleteByInboxID(ctx, inboxID); err != nil {
return fmt.Errorf("failed to delete pre-chat form: %w", err)
}
applogger.L().Infof("Deleted pre-chat form for inbox=%d", inboxID)
return nil
}
// SubmitPreChatForm processes a pre-chat form submission from a widget visitor.
// This bridges the form data into contact creation/identification before Init.
// Reference: Chatwoot widget SDK — pre-chat form submission creates/updates contact
func (s *WidgetService) SubmitPreChatForm(ctx context.Context, websiteToken string, submission model.PreChatFormSubmission) (*WidgetInitResponse, error) {
if websiteToken == "" {
return nil, errors.New("website_token is required")
}
inbox, err := s.findInboxByWebsiteToken(ctx, websiteToken)
if err != nil {
return nil, fmt.Errorf("invalid website_token: %w", err)
}
// Check if pre-chat form is enabled
form, err := s.preChatFormRepo.FindByInboxID(ctx, inbox.ID)
if err != nil || !form.Enabled {
// Fall back to channel_config check
widgetConfig, cfgErr := ParseWebWidgetConfig(inbox.ChannelConfig)
if cfgErr != nil || !widgetConfig.PreChatFieldsEnabled {
return nil, errors.New("pre-chat form is not enabled for this inbox")
}
}
// Build WidgetInitRequest from form submission
initReq := WidgetInitRequest{
WebsiteToken: websiteToken,
ContactName: submission.Name,
ContactEmail: submission.Email,
ContactPhone: submission.Phone,
}
// Run the standard Init flow — it will find/create contact using the form data
return s.Init(ctx, initReq)
}
// --- Widget File Upload Service Logic ---
// WidgetUploadRequest is the DTO for a file upload through the widget.
type WidgetUploadRequest struct {
WidgetToken string `json:"widget_token,omitempty"`
WebsiteToken string `json:"website_token,omitempty"`
InboxID uint `json:"inbox_id"`
File *multipart.FileHeader `json:"-"` // From multipart form (widget_token auth)
FileHeader *multipart.FileHeader `json:"-"` // From multipart form (website_token staging)
FileName string `json:"file_name,omitempty"`
FileSize int64 `json:"file_size,omitempty"`
}
// WidgetUploadResponse is returned after a successful file upload.
type WidgetUploadResponse struct {
UploadID uint `json:"upload_id"`
UploadUUID string `json:"upload_uuid,omitempty"`
WidgetToken string `json:"widget_token,omitempty"`
OriginalName string `json:"original_name"`
FileType string `json:"file_type"`
FileSize int64 `json:"file_size"`
FileURL string `json:"file_url,omitempty"`
ThumbURL string `json:"thumb_url,omitempty"`
Status string `json:"status"`
ExpiresAt time.Time `json:"expires_at"`
}
// StageFileUpload handles a file upload from the widget, identified by website_token.
// This is the public-facing endpoint that uses website_token (not widget_token) for initial staging.
// The upload is stored in "pending" status until the contact authenticates via Init.
// Reference: Chatwoot widget SDK — file upload before conversation starts
//
// Flow:
// 1. Resolve inbox by website_token
// 2. Validate file type and size against allowed types
// 3. Store file to disk/object storage
// 4. Create WidgetFileUpload record with status "pending" and a UUID for tracking
// 5. Return upload metadata (upload_uuid) for later message attachment
func (s *WidgetService) StageFileUpload(ctx context.Context, req WidgetUploadRequest, fileReader io.Reader) (*WidgetUploadResponse, error) {
if req.WebsiteToken == "" {
return nil, errors.New("website_token is required")
}
if req.FileHeader == nil {
return nil, errors.New("file is required")
}
// Step 1: Resolve inbox by website_token
inbox, err := s.inboxRepo.FindByWebsiteToken(ctx, req.WebsiteToken)
if err != nil {
return nil, fmt.Errorf("invalid website_token: %w", err)
}
inboxID := inbox.ID
// Step 2: Validate file
fileHeader := req.FileHeader
mimeType := fileHeader.Header.Get("Content-Type")
if mimeType == "" {
mimeType = detectMIMEFromFilename(fileHeader.Filename)
}
fileCategory := categorizeMIME(mimeType)
if fileCategory == "" {
return nil, fmt.Errorf("unsupported file type: %s", mimeType)
}
if !isMIMEAllowed(fileCategory, mimeType) {
return nil, fmt.Errorf("MIME type %s is not allowed for category %s", mimeType, fileCategory)
}
maxSize := model.WidgetUploadMaxSizeByType[fileCategory]
if fileHeader.Size > maxSize {
return nil, fmt.Errorf("file size %d exceeds maximum %d for type %s", fileHeader.Size, maxSize, fileCategory)
}
// Step 3: Store file
ext := filepath.Ext(fileHeader.Filename)
fileURL := fmt.Sprintf("/uploads/widget/%d/%d_staged%s", inboxID, time.Now().UnixMilli(), ext)
thumbURL := ""
if fileCategory == "image" {
thumbURL = fmt.Sprintf("/uploads/widget/%d/thumb_%d_staged%s", inboxID, time.Now().UnixMilli(), ext)
}
// Step 4: Create upload record with UUID
expiryDuration := 24 * time.Hour
uploadUUID := uuid.New().String()
upload := &model.WidgetFileUpload{
UploadUUID: uploadUUID,
InboxID: inboxID,
Status: model.WidgetFileUploadStatusPending,
OriginalName: fileHeader.Filename,
FileType: fileCategory,
MimeType: mimeType,
FileSize: fileHeader.Size,
FileURL: fileURL,
ThumbURL: thumbURL,
ExpiresAt: time.Now().Add(expiryDuration),
}
if err := s.fileUploadRepo.Create(ctx, upload); err != nil {
return nil, fmt.Errorf("failed to create upload record: %w", err)
}
applogger.L().Infof("Widget staged file upload: inbox=%d uuid=%s file=%s size=%d",
inboxID, uploadUUID, fileHeader.Filename, fileHeader.Size)
return &WidgetUploadResponse{
UploadUUID: uploadUUID,
UploadID: upload.ID,
OriginalName: upload.OriginalName,
FileType: upload.FileType,
FileSize: upload.FileSize,
FileURL: upload.FileURL,
ThumbURL: upload.ThumbURL,
Status: string(upload.Status),
ExpiresAt: upload.ExpiresAt,
}, nil
}
// GetFileUploadStatus retrieves the status of a staged file upload by UUID.
func (s *WidgetService) GetFileUploadStatus(ctx context.Context, websiteToken string, uploadUUID string) (*WidgetUploadResponse, error) {
if websiteToken == "" {
return nil, errors.New("website_token is required")
}
if uploadUUID == "" {
return nil, errors.New("upload_uuid is required")
}
// Verify website_token resolves to valid inbox
_, err := s.inboxRepo.FindByWebsiteToken(ctx, websiteToken)
if err != nil {
return nil, fmt.Errorf("invalid website_token: %w", err)
}
upload, err := s.fileUploadRepo.FindByUUID(ctx, uploadUUID)
if err != nil {
return nil, nil // Not found = nil, not error
}
return &WidgetUploadResponse{
UploadUUID: upload.UploadUUID,
UploadID: upload.ID,
OriginalName: upload.OriginalName,
FileType: upload.FileType,
FileSize: upload.FileSize,
FileURL: upload.FileURL,
ThumbURL: upload.ThumbURL,
Status: string(upload.Status),
ExpiresAt: upload.ExpiresAt,
}, nil
}
// UploadFile handles a file upload from the web widget (widget_token auth).
// Reference: Chatwoot widget SDK — attachment upload before sending message
//
// Flow:
// 1. Validate widget_token and resolve contact
// 2. Validate file type and size against allowed types
// 3. Store file to disk/object storage
// 4. Create WidgetFileUpload record with status "pending"
// 5. Return upload metadata (upload_id) for later message attachment
func (s *WidgetService) UploadFile(ctx context.Context, req WidgetUploadRequest, fileReader io.Reader) (*WidgetUploadResponse, error) {
if req.WidgetToken == "" {
return nil, errors.New("widget_token is required")
}
if req.File == nil {
return nil, errors.New("file is required")
}
// Step 1: Resolve contact by widget_token
contactInbox, err := s.contactInboxRepo.FindByPubsubToken(ctx, req.WidgetToken)
if err != nil {
return nil, fmt.Errorf("invalid widget_token: %w", err)
}
inboxID := contactInbox.InboxID
contactID := contactInbox.ContactID
// Step 2: Validate file
fileHeader := req.File
mimeType := fileHeader.Header.Get("Content-Type")
if mimeType == "" {
mimeType = detectMIMEFromFilename(fileHeader.Filename)
}
fileCategory := categorizeMIME(mimeType)
if fileCategory == "" {
return nil, fmt.Errorf("unsupported file type: %s", mimeType)
}
// Validate MIME type against allowed types
if !isMIMEAllowed(fileCategory, mimeType) {
return nil, fmt.Errorf("MIME type %s is not allowed for category %s", mimeType, fileCategory)
}
// Validate file size
maxSize := model.WidgetUploadMaxSizeByType[fileCategory]
if fileHeader.Size > maxSize {
return nil, fmt.Errorf("file size %d exceeds maximum %d for type %s", fileHeader.Size, maxSize, fileCategory)
}
// Step 3: Store file
// Production note: replace placeholder URL with actual storage service (S3, local disk) when upload infrastructure is wired.
// Current implementation generates a predictable URL pattern for development.
ext := filepath.Ext(fileHeader.Filename)
fileURL := fmt.Sprintf("/uploads/widget/%d/%d_%s%s", inboxID, time.Now().UnixMilli(), req.WidgetToken[:8], ext)
thumbURL := ""
if fileCategory == "image" {
thumbURL = fmt.Sprintf("/uploads/widget/%d/thumb_%d_%s%s", inboxID, time.Now().UnixMilli(), req.WidgetToken[:8], ext)
}
// Step 4: Create upload record
expiryDuration := 24 * time.Hour // Uploads expire after 24 hours if not attached
upload := &model.WidgetFileUpload{
WidgetToken: req.WidgetToken,
InboxID: inboxID,
ContactID: contactID,
Status: model.WidgetFileUploadStatusPending,
OriginalName: fileHeader.Filename,
FileType: fileCategory,
MimeType: mimeType,
FileSize: fileHeader.Size,
FileURL: fileURL,
ThumbURL: thumbURL,
ExpiresAt: time.Now().Add(expiryDuration),
}
if err := s.fileUploadRepo.Create(ctx, upload); err != nil {
return nil, fmt.Errorf("failed to create upload record: %w", err)
}
applogger.L().Infof("Widget file upload: inbox=%d contact=%d upload=%d file=%s size=%d",
inboxID, contactID, upload.ID, fileHeader.Filename, fileHeader.Size)
return &WidgetUploadResponse{
UploadID: upload.ID,
WidgetToken: upload.WidgetToken,
OriginalName: upload.OriginalName,
FileType: upload.FileType,
FileSize: upload.FileSize,
FileURL: upload.FileURL,
ThumbURL: upload.ThumbURL,
Status: string(upload.Status),
ExpiresAt: upload.ExpiresAt,
}, nil
}
// AttachUploadToMessage links a pending file upload to a message.
// Called internally after SendMessage when the message includes an attachment reference.
func (s *WidgetService) AttachUploadToMessage(ctx context.Context, uploadID uint, messageID uint) error {
upload, err := s.fileUploadRepo.FindByID(ctx, uploadID)
if err != nil {
return fmt.Errorf("upload not found: %w", err)
}
if upload.Status != model.WidgetFileUploadStatusPending {
return fmt.Errorf("upload is not in pending status (current: %s)", upload.Status)
}
upload.MessageID = &messageID
upload.Status = model.WidgetFileUploadStatusAttached
if err := s.fileUploadRepo.Update(ctx, upload); err != nil {
return fmt.Errorf("failed to attach upload: %w", err)
}
applogger.L().Infof("Attached upload=%d to message=%d", uploadID, messageID)
return nil
}
// CleanupExpiredUploads removes file uploads that have expired without being attached to messages.
// Should be called periodically by a background job.
func (s *WidgetService) CleanupExpiredUploads(ctx context.Context) (int64, error) {
now := time.Now()
affected, err := s.fileUploadRepo.BatchDeleteExpired(ctx, now)
if err != nil {
return 0, fmt.Errorf("failed to cleanup expired uploads: %w", err)
}
if affected > 0 {
applogger.L().Infof("Cleaned up %d expired widget file uploads", affected)
}
return affected, nil
}
// --- File helper functions ---
// detectMIMEFromFilename infers MIME type from the file extension when Content-Type header is missing.
func detectMIMEFromFilename(filename string) string {
ext := strings.ToLower(filepath.Ext(filename))
mimeMap := map[string]string{
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".svg": "image/svg+xml",
".mp3": "audio/mpeg",
".ogg": "audio/ogg",
".wav": "audio/wav",
".webm": "audio/webm",
".mp4": "video/mp4",
".pdf": "application/pdf",
".xls": "application/vnd.ms-excel",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".doc": "application/msword",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".txt": "text/plain",
".csv": "text/csv",
}
if m, ok := mimeMap[ext]; ok {
return m
}
return ""
}
// categorizeMIME maps a MIME type to a file category used by the widget.
func categorizeMIME(mimeType string) string {
if strings.HasPrefix(mimeType, "image/") {
return "image"
}
if strings.HasPrefix(mimeType, "audio/") {
return "audio"
}
if strings.HasPrefix(mimeType, "video/") {
return "video"
}
// Document types
docTypes := map[string]bool{
"application/pdf": true,
"application/vnd.ms-excel": true,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": true,
"application/msword": true,
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": true,
"text/plain": true,
"text/csv": true,
}
if docTypes[mimeType] {
return "file"
}
return ""
}
// isMIMEAllowed checks if a specific MIME type is in the allowed list for its category.
func isMIMEAllowed(category string, mimeType string) bool {
allowed, ok := model.WidgetUploadAllowedTypes[category]
if !ok {
return false
}
for _, m := range allowed {
if m == mimeType {
return true
}
}
return false
}