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 }