refactor(uploads): drop legacy multipart code paths
The presigned-URL upload flow (POST /api/uploads/presign + direct B2 POST
+ upload_ids[] in entity creation) is now the only image upload path. The
legacy multipart routes and DTO fields used by older clients are removed:
Removed:
- POST /api/uploads/image/ (legacy multipart upload → URL)
- POST /api/uploads/document/ (legacy multipart upload → URL)
- POST /api/uploads/completion/ (legacy multipart upload → URL)
- Multipart branch in POST /api/task-completions/ (now JSON-only)
- CreateTaskCompletionRequest.ImageURLs DTO field
- UpdateTaskCompletionRequest.ImageURLs DTO field
- CreateDocumentRequest.ImageURLs DTO field
- Service-layer ImageURLs loops in task_service.CreateCompletion,
task_service.UpdateCompletion, document_service.CreateDocument
- Tests exercising the removed paths
- Now-unused imports (strings/time/decimal) in task_handler.go
Kept:
- DELETE /api/uploads/ (orphan-cleanup endpoint, still useful)
- POST /api/uploads/presign/ (the new path)
- POST /api/documents/:id/images/ (uses storage_service.Upload directly,
same multipart pattern but separate code path; deferred for now)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1781,45 +1781,11 @@ func TestStaticDataHandler_RefreshStaticData(t *testing.T) {
|
||||
// =============================================================================
|
||||
// Upload Handler - Additional Error Paths
|
||||
// =============================================================================
|
||||
|
||||
func TestUploadHandler_UploadImage_NoFile(t *testing.T) {
|
||||
storageSvc := newTestStorageService("/var/uploads")
|
||||
handler := NewUploadHandler(storageSvc, nil)
|
||||
e := testutil.SetupTestRouter()
|
||||
|
||||
e.POST("/api/uploads/image", handler.UploadImage)
|
||||
|
||||
t.Run("no file returns 400", func(t *testing.T) {
|
||||
w := testutil.MakeRequest(e, "POST", "/api/uploads/image", nil, "")
|
||||
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUploadHandler_UploadDocument_NoFile(t *testing.T) {
|
||||
storageSvc := newTestStorageService("/var/uploads")
|
||||
handler := NewUploadHandler(storageSvc, nil)
|
||||
e := testutil.SetupTestRouter()
|
||||
|
||||
e.POST("/api/uploads/document", handler.UploadDocument)
|
||||
|
||||
t.Run("no file returns 400", func(t *testing.T) {
|
||||
w := testutil.MakeRequest(e, "POST", "/api/uploads/document", nil, "")
|
||||
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUploadHandler_UploadCompletion_NoFile(t *testing.T) {
|
||||
storageSvc := newTestStorageService("/var/uploads")
|
||||
handler := NewUploadHandler(storageSvc, nil)
|
||||
e := testutil.SetupTestRouter()
|
||||
|
||||
e.POST("/api/uploads/completion", handler.UploadCompletion)
|
||||
|
||||
t.Run("no file returns 400", func(t *testing.T) {
|
||||
w := testutil.MakeRequest(e, "POST", "/api/uploads/completion", nil, "")
|
||||
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
|
||||
})
|
||||
}
|
||||
//
|
||||
// Multipart upload handlers (UploadImage / UploadDocument / UploadCompletion)
|
||||
// were removed alongside the legacy /api/uploads/{image,document,completion}
|
||||
// routes. The presigned-URL flow (POST /api/uploads/presign) is exercised by
|
||||
// integration tests that hit the full pipeline.
|
||||
|
||||
func TestUploadHandler_DeleteFile_OwnershipDenied(t *testing.T) {
|
||||
storageSvc := newTestStorageService("/var/uploads")
|
||||
|
||||
@@ -3,11 +3,8 @@ package handlers
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/shopspring/decimal"
|
||||
|
||||
"github.com/treytartt/honeydue-api/internal/apperrors"
|
||||
"github.com/treytartt/honeydue-api/internal/dto/requests"
|
||||
@@ -393,7 +390,18 @@ func (h *TaskHandler) GetCompletion(c echo.Context) error {
|
||||
}
|
||||
|
||||
// CreateCompletion handles POST /api/task-completions/
|
||||
// Supports both JSON and multipart form data (for image uploads)
|
||||
//
|
||||
// JSON-only. Image attachments arrive via the presigned-URL flow:
|
||||
//
|
||||
// 1. Client POSTs /api/uploads/presign for each image and uploads bytes
|
||||
// directly to B2 using the returned policy.
|
||||
// 2. Client POSTs the resulting upload_ids[] in this request body.
|
||||
// 3. The service claims those pending_uploads rows and creates the
|
||||
// associated TaskCompletionImage rows.
|
||||
//
|
||||
// The legacy multipart path (with the API server proxying image bytes)
|
||||
// was removed alongside the 1 MB BodyLimit middleware that was rejecting
|
||||
// it anyway. See deploy-k3s/manifests/b2-lifecycle.md.
|
||||
func (h *TaskHandler) CreateCompletion(c echo.Context) error {
|
||||
user, err := middleware.MustGetAuthUser(c)
|
||||
if err != nil {
|
||||
@@ -402,65 +410,9 @@ func (h *TaskHandler) CreateCompletion(c echo.Context) error {
|
||||
userNow := middleware.GetUserNow(c)
|
||||
|
||||
var req requests.CreateTaskCompletionRequest
|
||||
|
||||
contentType := c.Request().Header.Get("Content-Type")
|
||||
|
||||
// Check if this is a multipart form request (image upload)
|
||||
if strings.HasPrefix(contentType, "multipart/form-data") {
|
||||
// Parse multipart form
|
||||
if err := c.Request().ParseMultipartForm(32 << 20); err != nil { // 32MB max
|
||||
return apperrors.BadRequest("error.failed_to_parse_form")
|
||||
}
|
||||
|
||||
// Parse task_id (required)
|
||||
taskIDStr := c.FormValue("task_id")
|
||||
if taskIDStr == "" {
|
||||
return apperrors.BadRequest("error.task_id_required")
|
||||
}
|
||||
taskID, err := strconv.ParseUint(taskIDStr, 10, 32)
|
||||
if err != nil {
|
||||
return apperrors.BadRequest("error.invalid_task_id_value")
|
||||
}
|
||||
req.TaskID = uint(taskID)
|
||||
|
||||
// Parse notes (optional)
|
||||
req.Notes = c.FormValue("notes")
|
||||
|
||||
// Parse actual_cost (optional)
|
||||
if costStr := c.FormValue("actual_cost"); costStr != "" {
|
||||
cost, err := decimal.NewFromString(costStr)
|
||||
if err == nil {
|
||||
req.ActualCost = &cost
|
||||
}
|
||||
}
|
||||
|
||||
// Parse completed_at (optional)
|
||||
if completedAtStr := c.FormValue("completed_at"); completedAtStr != "" {
|
||||
if t, err := time.Parse(time.RFC3339, completedAtStr); err == nil {
|
||||
req.CompletedAt = &t
|
||||
}
|
||||
}
|
||||
|
||||
// Handle multiple image uploads from various field names
|
||||
if h.storageService != nil && c.Request().MultipartForm != nil {
|
||||
for _, fieldName := range []string{"images", "image", "photo", "files"} {
|
||||
files := c.Request().MultipartForm.File[fieldName]
|
||||
for _, file := range files {
|
||||
result, err := h.storageService.Upload(c.Request().Context(), file, "completions")
|
||||
if err != nil {
|
||||
return apperrors.BadRequest("error.failed_to_upload_image")
|
||||
}
|
||||
req.ImageURLs = append(req.ImageURLs, result.URL)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Standard JSON request
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return apperrors.BadRequest("error.invalid_request")
|
||||
}
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return apperrors.BadRequest("error.invalid_request")
|
||||
}
|
||||
|
||||
if err := c.Validate(&req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -44,60 +44,6 @@ func (h *UploadHandler) SetUploadService(s *services.UploadService) {
|
||||
h.uploadService = s
|
||||
}
|
||||
|
||||
// UploadImage handles POST /api/uploads/image
|
||||
// Accepts multipart/form-data with "file" field
|
||||
func (h *UploadHandler) UploadImage(c echo.Context) error {
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
return apperrors.BadRequest("error.no_file_provided")
|
||||
}
|
||||
|
||||
// Get category from query param (default: images)
|
||||
category := c.QueryParam("category")
|
||||
if category == "" {
|
||||
category = "images"
|
||||
}
|
||||
|
||||
result, err := h.storageService.Upload(c.Request().Context(), file, category)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// UploadDocument handles POST /api/uploads/document
|
||||
// Accepts multipart/form-data with "file" field
|
||||
func (h *UploadHandler) UploadDocument(c echo.Context) error {
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
return apperrors.BadRequest("error.no_file_provided")
|
||||
}
|
||||
|
||||
result, err := h.storageService.Upload(c.Request().Context(), file, "documents")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// UploadCompletion handles POST /api/uploads/completion
|
||||
// For task completion photos
|
||||
func (h *UploadHandler) UploadCompletion(c echo.Context) error {
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
return apperrors.BadRequest("error.no_file_provided")
|
||||
}
|
||||
|
||||
result, err := h.storageService.Upload(c.Request().Context(), file, "completions")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// DeleteFileRequest is the request body for deleting a file.
|
||||
type DeleteFileRequest struct {
|
||||
URL string `json:"url" validate:"required"`
|
||||
|
||||
Reference in New Issue
Block a user