feat(macros): align attachment uploads

This commit is contained in:
2026-06-07 10:22:36 +08:00
parent 023ab10b31
commit 168f12070e
11 changed files with 504 additions and 53 deletions
+8 -4
View File
@@ -49,11 +49,11 @@ Hermes task landing checklist:
## Current Baseline
- Current tracking checkpoint: 2026-06-07 P3.90 SearchAPI filter-surface parity, prepared as `fix(search): align search filters`.
- Latest implementation checkpoint: this checkpoint, prepared as `fix(search): align search filters`.
- Latest documentation/tooling checkpoint: this tracker update records Chatwoot SearchAPI supported-filter parity. No route artifacts change.
- Current tracking checkpoint: 2026-06-07 P3.91 upload and macro attachment parity, prepared as `feat(macros): align attachment uploads`.
- Latest implementation checkpoint: this checkpoint, prepared as `feat(macros): align attachment uploads`.
- Latest documentation/tooling checkpoint: this tracker update records Chatwoot account upload and macro attachment parity. No route artifacts change.
- Plan landing status: complete for the current known Hermes plans and user-confirmed scope. Future work should update this file directly instead of opening a parallel tracker.
- Worktree status at this implementation checkpoint: SearchAPI filters now match the reference surface: date filters apply across SearchAPI result families, `from`/`inbox_id` apply only to message search, and unsupported local filters such as status, priority, labels, message type, content type, private, contact source, portal, article status, and locale are stripped before DB fallback or Meilisearch execution. This retains P3.89 fixed page size, P3.88 global result type scoping, P3.87 article payloads, P3.86 message payloads, P3.85 conversation payloads, P3.84 agent-sender filter parity, P3.83 conversation ordering, P3.82 contact-conversation reindex fanout, P3.81 conversation identity search, P3.80 search time-bound parity, P3.79 advanced-search feature gates, P3.78 message recency, P3.77 current-user inbox access, and prior checkpoints. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack.
- Worktree status at this implementation checkpoint: account upload now matches the reused Chatwoot helper by accepting multipart `attachment` and JSON `external_url`, returning raw `{ file_url, blob_id, blob_key }` without the local success/data envelope, and macro `send_attachment` actions now validate uploaded blob IDs, serialize `files`, and create outgoing attachment messages during macro execution. This retains P3.90 SearchAPI filter-surface parity, P3.89 fixed page size, P3.88 global result type scoping, P3.87 article payloads, P3.86 message payloads, P3.85 conversation payloads, P3.84 agent-sender filter parity, and prior checkpoints. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack.
- Next executable implementation checkpoint: continue Phase 2/3 drift audit for the next reused-frontend mismatch, or run B12 live smoke when the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack is available. Re-run Phase 6 placeholder audit after future route/smoke changes.
- `go test ./...` passes when run outside the restricted socket sandbox for the latest implementation baseline; the latest docs/tooling checkpoint verified `scripts/parity_frontend_smoke.sh --check` with workspace-local temp/cache dirs after `/tmp` was full.
- Route dump succeeds with `967` registered routes after profile MFA route tracking.
@@ -156,6 +156,7 @@ This table is the shortest authoritative handoff view. If an older lower section
| Priority | Workstream | Current state | Next checkpoint | Commit close rule |
| --- | --- | --- | --- | --- |
| 0 | P3.91 upload and macro attachment parity | Implemented for reused `uploadHelper` and macro settings flows: account upload accepts Chatwoot `attachment` multipart fields plus `external_url` JSON bodies, returns raw blob payloads consumed by the frontend, and macro `send_attachment` actions resolve those blob IDs into `files` payload entries and executable attachment messages. | Keep in Review; reopen from macro/automation attachment smoke, external fetch edge cases, or fresh reference evidence for ActiveStorage signed-id semantics not covered by the current direct-upload bridge. | Focused UploadHandler, MacroHandler, MacroService, and macro execution tests passed, including the external-url test outside the restricted socket sandbox; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. |
| 0 | P3.90 SearchAPI filter-surface parity | Implemented for reused SearchAPI endpoints: only Chatwoot-supported search filters are allowed through. `since/until` date filters remain available across result families when advanced search is enabled; `from` and `inbox_id` are preserved only for message search; unsupported local filters are stripped before DB fallback and Meilisearch execution. Global search applies message-only filters only to message results, so conversations/contacts/articles are not narrowed by message inbox or sender filters. | Keep in Review; reopen from B12 advanced-search smoke or fresh reference evidence for new upstream filter params, Searchkick advanced-search extensions, or endpoint-specific filter drift. | Focused SearchHandler, SearchService, and Meilisearch filter-surface tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. |
| 0 | P3.89 SearchAPI fixed page-size parity | Implemented for reused SearchAPI endpoints: GoChat now ignores local `per_page` query params and forces Chatwoot's fixed `.per(15)` page size for global, conversation, message, contact, and article search before DB fallback or Meilisearch execution. | Keep in Review; reopen from B12 search smoke or fresh reference evidence for endpoint-specific pagination changes, Kaminari default drift, or frontend callers adding explicit per-page support upstream. | Focused SearchHandler pagination tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. |
| 0 | P3.88 global search type-scope parity | Implemented for reused global SearchAPI: `GET /search` now scopes backend searches to the same four result families rendered by Chatwoot `search/index.json.jbuilder` (`conversations`, `contacts`, `messages`, `articles`) and ignores unsupported local `types=company/help_center` expansion before DB fallback or Meilisearch execution. | Keep in Review; reopen from B12 global-search smoke or fresh reference/frontend evidence for additional rendered result families, company search UI integration, or Searchkick all-type behavior drift. | Focused SearchHandler global type-scope tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. |
@@ -254,6 +255,7 @@ These rows are the executable development plan from this point forward. A checkp
| ID | Owner files | Reference files | Work to land | Exit gate |
| --- | --- | --- | --- | --- |
| P3.91 upload and macro attachment parity | `internal/handler/api/v1/upload_handler.go`, `internal/service/upload_service.go`, `internal/handler/api/v1/macro_handler.go`, `internal/automation/macro_service.go`, `internal/automation/action_service.go`, upload/macro tests | `reference/chatwoot/app/controllers/api/v1/accounts/upload_controller.rb`, `reference/chatwoot/app/controllers/concerns/attachment_concern.rb`, `reference/chatwoot/app/controllers/api/v1/accounts/macros_controller.rb`, `reference/chatwoot/app/models/macro.rb`, `reference/chatwoot/app/services/macros/execution_service.rb`, `reference/chatwoot/app/javascript/dashboard/helper/uploadHelper.js`, macro settings helper/components | Account upload now follows Chatwoot's reused frontend helper contract: multipart field `attachment`, JSON `external_url`, raw `file_url/blob_id/blob_key` response. Macro create/update validates `send_attachment` blob IDs, normalizes signed upload UUIDs to stored blob IDs, serializes `files` with `blob_id`, `filename`, `file_url`, and macro execution creates outgoing messages with persisted attachment rows. | Review by `feat(macros): align attachment uploads`; focused tests prove upload helper payloads, invalid attachment rejection, macro file serialization, durable/synchronous macro execution, and attachment message creation; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. |
| P3.90 SearchAPI filter-surface parity | `internal/handler/api/v1/search_handler.go`, `internal/search/search_service.go`, `internal/search/engine_meili.go`, SearchHandler/SearchService/Meilisearch tests | `reference/chatwoot/app/services/search_service.rb`, `reference/chatwoot/app/controllers/api/v1/accounts/search_controller.rb`, `reference/chatwoot/app/javascript/dashboard/api/search.js`, `reference/chatwoot/app/javascript/dashboard/modules/search/components/SearchView.vue`, `reference/chatwoot/app/javascript/dashboard/modules/search/helpers/searchHelper.js` | SearchAPI filters now match the reused dashboard/reference surface: `since/until` are retained as advanced date filters; `from` and `inbox_id` are retained for message search only; local filters not present in Chatwoot `SearchService` (`status`, `priority`, `labels`, `assignee_id`, `team_id`, `message_type`, `content_type`, `private`, `contact_source`, `portal_id`, `article_status`, `locale`) are stripped by the handler. Global search applies message-only filters only to message results, including Meilisearch multi-type searches. | Review by `fix(search): align search filters`; focused tests prove conversation endpoints strip unsupported filters, message endpoints keep only `from/inbox_id`, global service filters per result type, and Meilisearch applies explicit inbox only to messages; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. |
| P3.89 SearchAPI fixed page-size parity | `internal/handler/api/v1/search_handler.go`, `internal/handler/api/v1/search_handler_test.go` | `reference/chatwoot/app/services/search_service.rb`, `reference/chatwoot/app/controllers/api/v1/accounts/search_controller.rb`, `reference/chatwoot/app/javascript/dashboard/api/search.js` | SearchAPI handlers now match Chatwoot `SearchService` pagination by forcing `PerPage = 15` for global, conversation, message, contact, and article search regardless of local `per_page` query params. This keeps DB fallback and Meilisearch searches on the same fixed page-size contract used by every `page(params[:page]).per(15)` branch in the reference. | Review by `fix(search): align search page size`; focused SearchHandler tests prove explicit `per_page` on global/message search is ignored and the backend filter remains `15`; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. |
| P3.88 global search type-scope parity | `internal/handler/api/v1/search_handler.go`, `internal/handler/api/v1/search_handler_test.go` | `reference/chatwoot/app/controllers/api/v1/accounts/search_controller.rb`, `reference/chatwoot/app/services/search_service.rb#perform`, `reference/chatwoot/app/views/api/v1/accounts/search/index.json.jbuilder`, `reference/chatwoot/app/javascript/dashboard/api/search.js` | Global SearchAPI now follows Chatwoot's `search('all')` rendered surface by forcing backend execution to `conversations`, `contacts`, `messages`, and `articles` for `GET /search`, ignoring unsupported local `types=company/help_center` expansions before DB fallback or Meilisearch execution. | Review by `fix(search): scope global result types`; focused SearchHandler tests prove unsupported `types=company` does not call company search and the raw payload remains the four Chatwoot result families; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. |
@@ -368,6 +370,7 @@ This ledger records the committed parity checkpoints that future slices should b
| Commit | Scope | Verification summary | Follow-up state |
| --- | --- | --- | --- |
| `feat(macros): align attachment uploads` | Advances P3.91 with Chatwoot account upload and macro attachment parity. Account upload now accepts the frontend `attachment` form key and `external_url` JSON body, returns raw blob fields, and macro attachment actions validate/normalize those blob IDs, expose Chatwoot-style `files`, and create outgoing attachment messages during execution. | Focused UploadHandler/MacroHandler/MacroService tests passed, including the external-url upload test outside the restricted socket sandbox; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | Move P3.91 to Review; continue Phase 2/3 drift audit, B9.4 automation attachment depth if fresh smoke exposes it, Phase 6 placeholder audit, or B12 live smoke. |
| `fix(search): align search filters` | Advances P3.90 with Chatwoot SearchAPI filter-surface parity. Search handlers now strip unsupported local filter params, retain only date filters plus message-only `from/inbox_id`, and global search applies those message-only filters only to message results in both DB fallback and Meilisearch paths. | `go test ./internal/handler/api/v1 ./internal/search -run 'TestSearchHandler_Search(Conversations_IgnoresUnsupportedSearchFilters\|Messages_KeepsOnlyReferenceAdvancedFilters)\|TestGlobalSearch_AppliesReferenceFiltersPerResultType\|TestMeiliSearchEngine_SearchAppliesExplicitInboxOnlyToMessages' -count=1` passed with workspace-local temp/cache dirs; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | Move P3.90 to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. |
| `fix(search): align search page size` | Advances P3.89 with Chatwoot SearchAPI pagination parity. Global/entity search handlers now force the backend filter to the reference fixed page size of `15` and ignore local `per_page` query params before DB fallback or Meilisearch execution, matching every `page(params[:page]).per(15)` branch in `SearchService`. | `go test ./internal/handler/api/v1 -run 'TestSearchHandler_(GlobalSearch_WithFilterParams\|SearchMessages_Success)' -count=1` passed with workspace-local temp/cache dirs; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | Move P3.89 to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. |
| `fix(search): scope global result types` | Advances P3.88 with Chatwoot global SearchAPI result-family parity. `GET /search` now forces the backend filter to conversations, contacts, messages, and articles before DB fallback or Meilisearch execution, matching `SearchService#perform` plus `search/index.json.jbuilder` and preventing local company/help-center document types from affecting reused frontend global-search results. | `go test ./internal/handler/api/v1 -run 'TestSearchHandler_GlobalSearch_(Success\|WithFilterParams\|UsesReferenceResultTypes)' -count=1` passed with workspace-local temp/cache dirs; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | Move P3.88 to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. |
@@ -2740,3 +2743,4 @@ Verification milestone gates:
- 2026-06-07: P3.88 global search type-scope checkpoint prepared as `fix(search): scope global result types`; audited Chatwoot `SearchController#search`, `SearchService#perform`, `search/index.json.jbuilder`, and reused dashboard `SearchAPI.get`. GoChat global SearchAPI now forces backend execution to the four rendered Chatwoot result families (`conversations`, `contacts`, `messages`, `articles`) and ignores unsupported local `types=company/help_center` expansion before DB fallback or Meilisearch search, so hidden local document types cannot affect reused frontend global-search results. Focused SearchHandler tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change.
- 2026-06-07: P3.89 SearchAPI fixed page-size checkpoint prepared as `fix(search): align search page size`; audited Chatwoot `SearchService` pagination branches, `SearchController`, and reused dashboard `SearchAPI` callers. GoChat SearchAPI handlers now ignore local `per_page` query params and force the backend filter to Chatwoot's fixed `.per(15)` page size for global, conversation, message, contact, and article searches before DB fallback or Meilisearch execution. Focused SearchHandler pagination tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change.
- 2026-06-07: P3.90 SearchAPI filter-surface checkpoint prepared as `fix(search): align search filters`; audited Chatwoot `SearchService`, `SearchController`, reused dashboard `SearchAPI`, `SearchView.vue`, and search helper URL param generation. GoChat SearchAPI handlers now strip unsupported local filter params, retain `since/until` date filters, keep `from/inbox_id` only for message search, and make global search apply message-only filters only to message results across DB fallback and Meilisearch execution. Focused SearchHandler/SearchService/Meilisearch tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change.
- 2026-06-07: P3.91 upload and macro attachment checkpoint prepared as `feat(macros): align attachment uploads`; audited Chatwoot `UploadController`, `AttachmentConcern`, `MacrosController`, `Macro#file_base_data`, `Macros::ExecutionService#send_attachment`, and reused dashboard `uploadHelper` plus macro attachment helpers. GoChat account upload now accepts `attachment` multipart fields and `external_url` JSON, returns raw `file_url/blob_id/blob_key`, normalizes macro `send_attachment` actions from upload UUIDs to stored blob IDs, serializes macro `files`, rejects invalid blobs with raw `422`, and creates outgoing attachment messages from macro execution. Focused UploadHandler/MacroHandler/MacroService tests passed, including external-url upload outside the restricted socket sandbox; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change.
+43 -8
View File
@@ -555,24 +555,59 @@ func (s *ActionService) handleSendEmailTranscript(ctx context.Context, accountID
// Reference: Chatwoot send_attachment action
func (s *ActionService) handleSendAttachment(ctx context.Context, accountID, conversationID uint, action Action) error {
attachmentURL, _ := action.ActionParams["attachment_url"].(string)
if attachmentURL == "" {
return fmt.Errorf("send_attachment action requires 'attachment_url' param")
if attachmentURL != "" {
return s.createAttachmentMessage(ctx, accountID, conversationID, sourceID(action), model.DirectUpload{FileURL: attachmentURL, OriginalName: attachmentURL, FileType: "file"})
}
blobID := extractUintParam(action.ActionParams, "blob_id")
if blobID == 0 {
return fmt.Errorf("send_attachment action requires 'blob_id' param")
}
var upload model.DirectUpload
if err := s.db.DB().WithContext(ctx).Where("account_id = ? AND id = ?", accountID, blobID).First(&upload).Error; err != nil {
return err
}
return s.createAttachmentMessage(ctx, accountID, conversationID, sourceID(action), upload)
}
// Create a message with attachment content type
senderID := uint(0) // Bot sender
func (s *ActionService) createAttachmentMessage(ctx context.Context, accountID, conversationID, senderID uint, upload model.DirectUpload) error {
var conversation model.Conversation
if err := s.db.DB().WithContext(ctx).Where("account_id = ? AND id = ?", accountID, conversationID).First(&conversation).Error; err != nil {
return err
}
senderType := "agent"
if senderID == 0 {
senderType = "bot"
}
msg := &model.Message{
ConversationID: conversationID,
AccountID: accountID,
InboxID: conversation.InboxID,
SenderID: &senderID,
SenderType: "bot",
Content: attachmentURL,
SenderType: senderType,
Content: "",
ContentType: "file",
MessageType: "outgoing",
Private: false,
Status: "sent",
}
return s.db.DB().WithContext(ctx).Create(msg).Error
return s.db.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(msg).Error; err != nil {
return err
}
attachment := &model.Attachment{
MessageID: msg.ID,
AccountID: accountID,
FileType: upload.FileType,
FileURL: upload.FileURL,
ThumbURL: upload.ThumbURL,
FileSize: int(upload.FileSize),
FileName: upload.OriginalName,
}
if attachment.FileType == "" {
attachment.FileType = "file"
}
return tx.Create(attachment).Error
})
}
// handleAddPrivateNote adds a private note (internal comment) to the conversation.
@@ -39,6 +39,8 @@ func setupAutomationTestDB(t *testing.T) *gorm.DB {
&model.ContactInbox{},
&model.Conversation{},
&model.Message{},
&model.Attachment{},
&model.DirectUpload{},
&model.Team{},
&model.TeamMember{},
&model.SlaPolicy{},
+140 -2
View File
@@ -2,7 +2,9 @@ package automation
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"github.com/gochat/gochat/internal/model"
@@ -44,6 +46,7 @@ func (s *MacroService) GetByID(ctx context.Context, id uint) (*Macro, error) {
if err := s.db.DB().WithContext(ctx).Preload("CreatedBy").Preload("UpdatedBy").First(&macro, id).Error; err != nil {
return nil, err
}
_ = s.hydrateMacroFiles(ctx, &macro)
return &macro, nil
}
@@ -56,6 +59,7 @@ func (s *MacroService) GetByIDForAccount(ctx context.Context, accountID, id uint
First(&macro, id).Error; err != nil {
return nil, err
}
_ = s.hydrateMacroFiles(ctx, &macro)
return &macro, nil
}
@@ -73,6 +77,9 @@ func (s *MacroService) ListByAccount(ctx context.Context, accountID uint, userID
if err := query.Preload("CreatedBy").Preload("UpdatedBy").Order("id ASC").Find(&macros).Error; err != nil {
return nil, err
}
for i := range macros {
_ = s.hydrateMacroFiles(ctx, &macros[i])
}
return macros, nil
}
@@ -84,6 +91,9 @@ func (s *MacroService) Create(ctx context.Context, macro *Macro) error {
if macro.Actions == nil {
macro.Actions = Actions{}
}
if err := s.normalizeMacroAttachmentActions(ctx, macro.AccountID, &macro.Actions); err != nil {
return err
}
macro.Active = true
// Use Select to force all fields including zero-value bool Active=false.
// Without Select, GORM skips zero-value fields and uses column defaults.
@@ -94,7 +104,10 @@ func (s *MacroService) Create(ctx context.Context, macro *Macro) error {
).Create(macro).Error; err != nil {
return err
}
return s.db.DB().WithContext(ctx).Preload("CreatedBy").Preload("UpdatedBy").First(macro, macro.ID).Error
if err := s.db.DB().WithContext(ctx).Preload("CreatedBy").Preload("UpdatedBy").First(macro, macro.ID).Error; err != nil {
return err
}
return s.hydrateMacroFiles(ctx, macro)
}
// Update updates an existing macro.
@@ -109,7 +122,12 @@ func (s *MacroService) UpdateForAccount(ctx context.Context, accountID uint, mac
return nil, err
}
existing.Name = macro.Name
existing.Actions = macro.Actions
if macro.Actions != nil {
if err := s.normalizeMacroAttachmentActions(ctx, accountID, &macro.Actions); err != nil {
return nil, err
}
existing.Actions = macro.Actions
}
existing.Visibility = macro.Visibility
existing.UpdatedByID = macro.UpdatedByID
if err := s.Update(ctx, existing); err != nil {
@@ -118,6 +136,126 @@ func (s *MacroService) UpdateForAccount(ctx context.Context, accountID uint, mac
return s.GetByIDForAccount(ctx, accountID, existing.ID)
}
func (s *MacroService) normalizeMacroAttachmentActions(ctx context.Context, accountID uint, actions *Actions) error {
if actions == nil {
return nil
}
for i := range *actions {
action := &(*actions)[i]
if action.ActionName != "send_attachment" {
continue
}
if action.ActionParams == nil {
return fmt.Errorf("invalid attachment")
}
blobID := firstMacroBlobID(action.ActionParams)
upload, err := s.findMacroUpload(ctx, accountID, blobID)
if err != nil {
return fmt.Errorf("invalid attachment")
}
action.ActionParams["blob_id"] = upload.ID
delete(action.ActionParams, "attachment_url")
}
return nil
}
func firstMacroBlobID(params map[string]interface{}) interface{} {
if value := params["blob_id"]; value != nil {
return value
}
return params["attachment_url"]
}
func (s *MacroService) findMacroUpload(ctx context.Context, accountID uint, value interface{}) (*model.DirectUpload, error) {
switch v := value.(type) {
case string:
if parsed, err := strconv.ParseUint(v, 10, 64); err == nil {
return s.findMacroUploadByID(ctx, accountID, uint(parsed))
}
var upload model.DirectUpload
if err := s.db.DB().WithContext(ctx).Where("account_id = ? AND upload_uuid = ?", accountID, v).First(&upload).Error; err != nil {
return nil, err
}
return &upload, nil
case float64:
return s.findMacroUploadByID(ctx, accountID, uint(v))
case int:
return s.findMacroUploadByID(ctx, accountID, uint(v))
case uint:
return s.findMacroUploadByID(ctx, accountID, v)
case json.Number:
parsed, err := strconv.ParseUint(string(v), 10, 64)
if err != nil {
return nil, err
}
return s.findMacroUploadByID(ctx, accountID, uint(parsed))
default:
return nil, gorm.ErrRecordNotFound
}
}
func (s *MacroService) findMacroUploadByID(ctx context.Context, accountID, id uint) (*model.DirectUpload, error) {
var upload model.DirectUpload
if err := s.db.DB().WithContext(ctx).Where("account_id = ? AND id = ?", accountID, id).First(&upload).Error; err != nil {
return nil, err
}
return &upload, nil
}
func (s *MacroService) hydrateMacroFiles(ctx context.Context, macro *Macro) error {
ids := macroAttachmentBlobIDs(macro.Actions)
if len(ids) == 0 {
macro.Files = nil
return nil
}
var uploads []model.DirectUpload
if err := s.db.DB().WithContext(ctx).Where("account_id = ? AND id IN ?", macro.AccountID, ids).Order("id ASC").Find(&uploads).Error; err != nil {
return err
}
files := make([]MacroFile, 0, len(uploads))
for _, upload := range uploads {
files = append(files, MacroFile{ID: upload.ID, MacroID: macro.ID, FileType: upload.MimeType, AccountID: upload.AccountID, FileURL: upload.FileURL, BlobID: upload.ID, Filename: upload.OriginalName})
}
macro.Files = files
return nil
}
func macroAttachmentBlobIDs(actions Actions) []uint {
seen := map[uint]bool{}
ids := []uint{}
for _, action := range actions {
if action.ActionName != "send_attachment" {
continue
}
id := macroBlobIDAsUint(action.ActionParams["blob_id"])
if id == 0 || seen[id] {
continue
}
seen[id] = true
ids = append(ids, id)
}
return ids
}
func macroBlobIDAsUint(value interface{}) uint {
switch v := value.(type) {
case uint:
return v
case int:
return uint(v)
case float64:
return uint(v)
case string:
parsed, _ := strconv.ParseUint(v, 10, 64)
return uint(parsed)
case json.Number:
parsed, _ := strconv.ParseUint(string(v), 10, 64)
return uint(parsed)
default:
return 0
}
}
// Delete deletes a macro by ID.
func (s *MacroService) Delete(ctx context.Context, id uint) error {
return s.db.DB().WithContext(ctx).Delete(&Macro{}, id).Error
+41
View File
@@ -256,3 +256,44 @@ func TestMacroService_ExecuteForDisplayIDsQueuesDurableMacroJob(t *testing.T) {
t.Fatalf("expected macro job to add label: %v", err)
}
}
func TestMacroService_ExecuteForDisplayIDsCreatesAttachmentMessages(t *testing.T) {
dbProvider := setupAutomationTestDBProvider(t)
db := dbProvider.DB()
accountID, userID := seedTestAccount(db, t)
inboxID := seedTestInbox(db, t, accountID)
contactID := seedTestContact(db, t, accountID)
displayID := uint(717)
conversationID := seedTestConversationWithDetails(db, t, accountID, inboxID, contactID, "open", "low", "web", 0)
if err := db.Model(&model.Conversation{}).Where("id = ?", conversationID).Update("display_id", displayID).Error; err != nil {
t.Fatalf("set display id: %v", err)
}
upload := &model.DirectUpload{UploadUUID: "macro-exec-upload", AccountID: accountID, Status: model.DirectUploadStatusPending, Source: model.DirectUploadSourceAccount, OriginalName: "macro.pdf", FileType: "file", MimeType: "application/pdf", FileSize: 456, FileURL: "/uploads/account/1/macro.pdf"}
if err := db.Create(upload).Error; err != nil {
t.Fatalf("create upload: %v", err)
}
macro := &Macro{AccountID: accountID, Name: "file macro", Actions: Actions{{ActionName: "send_attachment", ActionParams: map[string]interface{}{"blob_id": "macro-exec-upload"}}}, Visibility: MacroVisibilityGlobal, CreatedByID: userID, UpdatedByID: userID}
svc := NewMacroService(dbProvider)
if err := svc.Create(context.Background(), macro); err != nil {
t.Fatalf("create macro: %v", err)
}
if err := svc.ExecuteForDisplayIDs(context.Background(), accountID, macro.ID, []uint{displayID}, userID); err != nil {
t.Fatalf("execute macro: %v", err)
}
var message model.Message
if err := db.Where("conversation_id = ? AND content_type = ?", conversationID, "file").First(&message).Error; err != nil {
t.Fatalf("expected file message: %v", err)
}
if message.SenderID == nil || *message.SenderID != userID {
t.Fatalf("expected macro sender %d, got %#v", userID, message.SenderID)
}
var attachment model.Attachment
if err := db.Where("message_id = ?", message.ID).First(&attachment).Error; err != nil {
t.Fatalf("expected attachment: %v", err)
}
if attachment.FileURL != upload.FileURL || attachment.FileName != upload.OriginalName {
t.Fatalf("unexpected attachment: %#v", attachment)
}
}
+11
View File
@@ -123,10 +123,21 @@ type Macro struct {
UpdatedByID uint `gorm:"index;not null" json:"updated_by_id"`
CreatedBy *model.User `gorm:"foreignKey:CreatedByID" json:"created_by,omitempty"`
UpdatedBy *model.User `gorm:"foreignKey:UpdatedByID" json:"updated_by,omitempty"`
Files []MacroFile `gorm:"-" json:"files,omitempty"`
}
func (Macro) TableName() string { return "macros" }
type MacroFile struct {
ID uint `json:"id"`
MacroID uint `json:"macro_id"`
FileType string `json:"file_type"`
AccountID uint `json:"account_id"`
FileURL string `json:"file_url"`
BlobID uint `json:"blob_id"`
Filename string `json:"filename"`
}
// ===========================
// CsatSurveyResponse model
// ===========================
+35 -9
View File
@@ -105,6 +105,10 @@ func (h *MacroHandler) Create(c *gin.Context) {
}
if svcErr := h.svc.Create(c.Request.Context(), macro); svcErr != nil {
if strings.Contains(svcErr.Error(), "invalid attachment") {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid attachment"})
return
}
handleServiceError(c, svcErr)
return
}
@@ -154,6 +158,10 @@ func (h *MacroHandler) Update(c *gin.Context) {
updated, svcErr := h.svc.UpdateForAccount(c.Request.Context(), accountID, macro)
if svcErr != nil {
if strings.Contains(svcErr.Error(), "invalid attachment") {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid attachment"})
return
}
handleServiceError(c, svcErr)
return
}
@@ -312,9 +320,9 @@ type macroActionRequest struct {
}
type macroRequest struct {
Name string `json:"name"`
Visibility interface{} `json:"visibility"`
Actions []macroActionRequest `json:"actions"`
Name string `json:"name"`
Visibility interface{} `json:"visibility"`
Actions *[]macroActionRequest `json:"actions"`
}
func bindMacroRequest(c *gin.Context) (*automation.Macro, error) {
@@ -322,13 +330,16 @@ func bindMacroRequest(c *gin.Context) (*automation.Macro, error) {
if err := c.ShouldBindJSON(&req); err != nil {
return nil, err
}
actions := make(automation.Actions, 0, len(req.Actions))
for _, actionReq := range req.Actions {
params, err := normalizeAutomationActionParams(actionReq.ActionName, actionReq.ActionParams)
if err != nil {
return nil, err
var actions automation.Actions
if req.Actions != nil {
actions = make(automation.Actions, 0, len(*req.Actions))
for _, actionReq := range *req.Actions {
params, err := normalizeAutomationActionParams(actionReq.ActionName, actionReq.ActionParams)
if err != nil {
return nil, err
}
actions = append(actions, automation.Action{ActionName: actionReq.ActionName, ActionParams: params})
}
actions = append(actions, automation.Action{ActionName: actionReq.ActionName, ActionParams: params})
}
return &automation.Macro{
Name: strings.TrimSpace(req.Name),
@@ -392,6 +403,21 @@ func serializeMacro(macro *automation.Macro) gin.H {
if macro.UpdatedBy != nil && macro.UpdatedBy.ID != 0 {
item["updated_by"] = serializeMacroAgent(macro.UpdatedBy)
}
if len(macro.Files) > 0 {
files := make([]gin.H, 0, len(macro.Files))
for _, file := range macro.Files {
files = append(files, gin.H{
"id": file.ID,
"macro_id": file.MacroID,
"file_type": file.FileType,
"account_id": file.AccountID,
"file_url": file.FileURL,
"blob_id": file.BlobID,
"filename": file.Filename,
})
}
item["files"] = files
}
return item
}
@@ -41,6 +41,7 @@ func (s *MacroHandlerTestSuite) SetupSuite() {
s.Require().NoError(err)
s.Require().NoError(db.AutoMigrate(
&model.Account{}, &model.User{}, &model.AccountUser{}, &model.Inbox{}, &model.Contact{}, &model.Conversation{}, &model.Message{},
&model.Attachment{}, &model.DirectUpload{},
&automation.Macro{}, &automation.MacroExecution{}, &automation.ConversationLabel{}, &automation.ConversationMute{},
))
s.db = db
@@ -54,6 +55,62 @@ func (s *MacroHandlerTestSuite) SetupSuite() {
s.db.Create(s.user)
}
func (s *MacroHandlerTestSuite) TestCreate_AttachmentActionAcceptsSignedUploadAndSerializesFiles() {
upload := &model.DirectUpload{
UploadUUID: "macro-upload-signed-id",
AccountID: s.account.ID,
Status: model.DirectUploadStatusPending,
Source: model.DirectUploadSourceAccount,
OriginalName: "avatar.png",
FileType: "image",
MimeType: "image/png",
FileSize: 123,
FileURL: "/uploads/account/1/avatar.png",
ThumbURL: "/uploads/account/1/avatar.png",
}
s.Require().NoError(s.db.Create(upload).Error)
r := gin.New()
r.POST("/api/v1/accounts/:account_id/macros", func(c *gin.Context) {
c.Set("user_id", float64(s.user.ID))
c.Next()
}, s.handler.Create)
body := []byte(`{"name":"send file","visibility":"global","actions":[{"action_name":"send_attachment","action_params":["macro-upload-signed-id"]}]}`)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/macros", s.account.ID), bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
var resp map[string]interface{}
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
payload := resp["payload"].(map[string]interface{})
actions := payload["actions"].([]interface{})
action := actions[0].(map[string]interface{})
assert.Equal(s.T(), []interface{}{float64(upload.ID)}, action["action_params"])
files := payload["files"].([]interface{})
s.Require().Len(files, 1)
file := files[0].(map[string]interface{})
assert.Equal(s.T(), float64(upload.ID), file["blob_id"])
assert.Equal(s.T(), "avatar.png", file["filename"])
}
func (s *MacroHandlerTestSuite) TestCreate_AttachmentActionRejectsInvalidBlob() {
r := gin.New()
r.POST("/api/v1/accounts/:account_id/macros", func(c *gin.Context) {
c.Set("user_id", float64(s.user.ID))
c.Next()
}, s.handler.Create)
body := []byte(`{"name":"bad file","visibility":"global","actions":[{"action_name":"send_attachment","action_params":["missing-upload"]}]}`)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/macros", s.account.ID), bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusUnprocessableEntity, w.Code)
}
func (s *MacroHandlerTestSuite) TearDownSuite() {
if s.db != nil {
sqlDB, _ := s.db.DB()
+35 -10
View File
@@ -1,10 +1,12 @@
package v1
import (
"errors"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/gochat/gochat/internal/service"
"github.com/gochat/gochat/pkg/response"
@@ -29,21 +31,44 @@ func (h *UploadHandler) Upload(c *gin.Context) {
return
}
fileHeader, err := c.FormFile("file")
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, "file is required")
return
var result *service.UploadResponse
var svcErr error
if strings.Contains(c.GetHeader("Content-Type"), "application/json") {
var req struct {
ExternalURL string `json:"external_url"`
}
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.ExternalURL) == "" {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "missing input"})
return
}
result, svcErr = h.svc.AccountUploadFromURL(c.Request.Context(), accountID, strings.TrimSpace(req.ExternalURL))
} else {
fileHeader, err := c.FormFile("attachment")
if err != nil {
fileHeader, err = c.FormFile("file")
}
if err != nil {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "missing input"})
return
}
result, svcErr = h.svc.AccountUpload(c.Request.Context(), accountID, service.AccountUploadRequest{
FileHeader: fileHeader,
})
}
result, svcErr := h.svc.AccountUpload(c.Request.Context(), accountID, service.AccountUploadRequest{
FileHeader: fileHeader,
})
if svcErr != nil {
handleServiceError(c, svcErr)
status := http.StatusUnprocessableEntity
if errors.Is(svcErr, gorm.ErrRecordNotFound) {
status = http.StatusNotFound
}
c.JSON(status, gin.H{"error": svcErr.Error()})
return
}
response.OK(c, result)
c.JSON(http.StatusOK, gin.H{
"file_url": result.FileURL,
"blob_id": result.UploadUUID,
"blob_key": result.UploadUUID,
})
}
// DirectUpload handles POST /api/v1/widget/direct_uploads — widget direct file upload.
+60 -2
View File
@@ -9,6 +9,7 @@ import (
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/gin-gonic/gin"
@@ -55,9 +56,13 @@ func setupUploadHandlerRouter(h *UploadHandler) *gin.Engine {
// makeMultipartUploadBody creates a multipart form body with a file field.
func makeMultipartUploadBody(filename string, content []byte) (body *bytes.Buffer, contentType string, err error) {
return makeMultipartUploadBodyWithField("file", filename, content)
}
func makeMultipartUploadBodyWithField(fieldName, filename string, content []byte) (body *bytes.Buffer, contentType string, err error) {
body = &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("file", filename)
part, err := writer.CreateFormFile(fieldName, filename)
if err != nil {
return nil, "", err
}
@@ -79,7 +84,60 @@ func TestUploadHandler_Upload_NoFile(t *testing.T) {
// Set account_id in context (simulating middleware)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Equal(t, http.StatusUnprocessableEntity, w.Code)
}
func TestUploadHandler_Upload_ChatwootAttachmentFieldRawPayload(t *testing.T) {
tmpDir := t.TempDir()
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.DirectUpload{}))
uploadSvc := service.NewUploadService(repository.NewDirectUploadRepo(db), &config.Config{Storage: config.StorageConfig{LocalPath: tmpDir, MaxFileSize: 50 << 20}})
router := setupUploadHandlerRouter(NewUploadHandler(uploadSvc))
body, contentType, err := makeMultipartUploadBodyWithField("attachment", "macro.png", []byte("fake png"))
require.NoError(t, err)
req, _ := http.NewRequest("POST", "/api/v1/accounts/1/upload", body)
req.Header.Set("Content-Type", contentType)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var payload map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &payload))
assert.NotContains(t, payload, "success")
assert.NotEmpty(t, payload["file_url"])
assert.NotEmpty(t, payload["blob_id"])
assert.Equal(t, payload["blob_id"], payload["blob_key"])
}
func TestUploadHandler_Upload_ChatwootExternalURLRawPayload(t *testing.T) {
tmpDir := t.TempDir()
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.DirectUpload{}))
uploadSvc := service.NewUploadService(repository.NewDirectUploadRepo(db), &config.Config{Storage: config.StorageConfig{LocalPath: tmpDir, MaxFileSize: 50 << 20}})
router := setupUploadHandlerRouter(NewUploadHandler(uploadSvc))
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write([]byte("external image"))
}))
defer server.Close()
body := strings.NewReader(`{"external_url":"` + server.URL + `/image.png"}`)
req, _ := http.NewRequest("POST", "/api/v1/accounts/1/upload", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var payload map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &payload))
assert.NotContains(t, payload, "success")
assert.NotEmpty(t, payload["file_url"])
assert.NotEmpty(t, payload["blob_id"])
}
func TestUploadHandler_DirectUpload_NoFile(t *testing.T) {
+72 -18
View File
@@ -1,6 +1,7 @@
package service
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
@@ -9,6 +10,8 @@ import (
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
@@ -134,6 +137,48 @@ func (s *UploadService) AccountUpload(ctx context.Context, accountID uint, req A
return s.processUpload(ctx, accountID, req.FileHeader, model.DirectUploadSourceAccount)
}
func (s *UploadService) AccountUploadFromURL(ctx context.Context, accountID uint, externalURL string) (*UploadResponse, error) {
if accountID == 0 {
return nil, errors.New("account_id is required")
}
parsed, err := url.ParseRequestURI(externalURL)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return nil, errors.New("invalid url")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, externalURL, nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch external url: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, fmt.Errorf("failed to fetch external url: status %d", resp.StatusCode)
}
maxSize := int64(s.cfg.Storage.MaxFileSize)
if maxSize <= 0 {
maxSize = 50 << 20
}
data, err := io.ReadAll(io.LimitReader(resp.Body, maxSize+1))
if err != nil {
return nil, err
}
if int64(len(data)) > maxSize {
return nil, errors.New("file too large")
}
filename := filepath.Base(parsed.Path)
if filename == "." || filename == "/" || filename == "" {
filename = "upload"
}
contentType := resp.Header.Get("Content-Type")
if idx := strings.Index(contentType, ";"); idx >= 0 {
contentType = strings.TrimSpace(contentType[:idx])
}
return s.processUploadContent(ctx, accountID, model.DirectUploadSourceAccount, filename, contentType, int64(len(data)), bytes.NewReader(data))
}
// --- Account Direct Upload (staged for message attachment) ---
// AccountDirectUpload handles a staged file upload from the dashboard (account-scoped).
@@ -372,12 +417,20 @@ func (s *UploadService) createActiveStorageDirectUpload(ctx context.Context, acc
}
func (s *UploadService) processUpload(ctx context.Context, accountID uint, fileHeader *multipart.FileHeader, source model.DirectUploadSource) (*UploadResponse, error) {
// Step 1: Validate file
mimeType := fileHeader.Header.Get("Content-Type")
src, err := fileHeader.Open()
if err != nil {
return nil, fmt.Errorf("failed to open uploaded file: %w", err)
}
defer src.Close()
return s.processUploadContent(ctx, accountID, source, fileHeader.Filename, mimeType, fileHeader.Size, src)
}
func (s *UploadService) processUploadContent(ctx context.Context, accountID uint, source model.DirectUploadSource, filename, mimeType string, size int64, reader io.Reader) (*UploadResponse, error) {
if mimeType == "" || mimeType == "application/octet-stream" {
// Fall back to filename-based detection when Content-Type is empty
// or the generic default (browsers/multipart forms often send this).
detected := detectUploadMIMEFromFilename(fileHeader.Filename)
detected := detectUploadMIMEFromFilename(filename)
if detected != "" && detected != "application/octet-stream" {
mimeType = detected
}
@@ -396,13 +449,13 @@ func (s *UploadService) processUpload(ctx context.Context, accountID uint, fileH
if maxSize == 0 {
maxSize = int64(s.cfg.Storage.MaxFileSize)
}
if fileHeader.Size > maxSize {
return nil, fmt.Errorf("file size %d exceeds maximum %d for type %s", fileHeader.Size, maxSize, fileCategory)
if size > maxSize {
return nil, fmt.Errorf("file size %d exceeds maximum %d for type %s", size, maxSize, fileCategory)
}
// Step 2: Store file to disk
ext := filepath.Ext(fileHeader.Filename)
fileURL, thumbURL, err := s.saveFileToDisk(accountID, source, ext, fileHeader)
ext := filepath.Ext(filename)
fileURL, thumbURL, err := s.saveUploadReader(accountID, source, ext, mimeType, reader)
if err != nil {
return nil, fmt.Errorf("failed to save file: %w", err)
}
@@ -415,10 +468,10 @@ func (s *UploadService) processUpload(ctx context.Context, accountID uint, fileH
AccountID: accountID,
Status: model.DirectUploadStatusPending,
Source: source,
OriginalName: fileHeader.Filename,
OriginalName: filename,
FileType: fileCategory,
MimeType: mimeType,
FileSize: fileHeader.Size,
FileSize: size,
FileURL: fileURL,
ThumbURL: thumbURL,
ExpiresAt: time.Now().Add(expiryDuration),
@@ -431,7 +484,7 @@ func (s *UploadService) processUpload(ctx context.Context, accountID uint, fileH
}
applogger.L().Infof("Direct file upload: account=%d source=%s uuid=%s file=%s size=%d",
accountID, source, uploadUUID, fileHeader.Filename, fileHeader.Size)
accountID, source, uploadUUID, filename, size)
return &UploadResponse{
UploadID: upload.ID,
@@ -448,6 +501,15 @@ func (s *UploadService) processUpload(ctx context.Context, accountID uint, fileH
}
func (s *UploadService) saveFileToDisk(accountID uint, source model.DirectUploadSource, ext string, fileHeader *multipart.FileHeader) (string, string, error) {
src, err := fileHeader.Open()
if err != nil {
return "", "", fmt.Errorf("failed to open uploaded file: %w", err)
}
defer src.Close()
return s.saveUploadReader(accountID, source, ext, fileHeader.Header.Get("Content-Type"), src)
}
func (s *UploadService) saveUploadReader(accountID uint, source model.DirectUploadSource, ext, mimeType string, reader io.Reader) (string, string, error) {
localPath := s.cfg.Storage.LocalPath
if localPath == "" {
localPath = "./uploads"
@@ -464,13 +526,6 @@ func (s *UploadService) saveFileToDisk(accountID uint, source model.DirectUpload
fileName := baseName + ext
fullPath := filepath.Join(dirPath, fileName)
// Open uploaded file
src, err := fileHeader.Open()
if err != nil {
return "", "", fmt.Errorf("failed to open uploaded file: %w", err)
}
defer src.Close()
// Create destination file
dst, err := os.Create(fullPath)
if err != nil {
@@ -479,7 +534,7 @@ func (s *UploadService) saveFileToDisk(accountID uint, source model.DirectUpload
defer dst.Close()
// Copy file content
if _, err := io.Copy(dst, src); err != nil {
if _, err := io.Copy(dst, reader); err != nil {
os.Remove(fullPath) // Clean up on failure
return "", "", fmt.Errorf("failed to copy file content: %w", err)
}
@@ -488,7 +543,6 @@ func (s *UploadService) saveFileToDisk(accountID uint, source model.DirectUpload
thumbURL := ""
// For images, we reference the same path (thumbnail generation can be added later)
mimeType := fileHeader.Header.Get("Content-Type")
if strings.HasPrefix(mimeType, "image/") {
thumbURL = fileURL // Placeholder: same as fileURL for now
}