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.
632 lines
21 KiB
Go
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
|
|
} |