diff --git a/cmd/web/main.go b/cmd/web/main.go index 32f72ba..de5256f 100644 --- a/cmd/web/main.go +++ b/cmd/web/main.go @@ -210,7 +210,7 @@ func main() { auditLogger := audit.NewLogger(logger) userService := user.NewService(userRepository, logger, userAuthService, userValidator, notificationSDK, redisClient, auditLogger) categoryService := company_category.NewService(categoryRepository, logger) - companyService := company.NewService(companyRepository, categoryService, logger) + companyService := company.NewService(companyRepository, categoryService, fileStoreService, logger) customerService := customer.NewService(customerRepository, logger, customerAuthService, companyService, redisClient, notificationSDK, customerValidator, auditLogger) tenderService := tender.NewService(tenderRepository, companyService, customerService, logger, aiSummarizerClient, aiSummarizerStorage, conf.AISummarizer.DefaultLanguage) feedbackService := feedback.NewService(feedbackRepo, tenderService, companyService, logger) diff --git a/cmd/web/router/routes.go b/cmd/web/router/routes.go index fa4c3fb..44439d2 100644 --- a/cmd/web/router/routes.go +++ b/cmd/web/router/routes.go @@ -77,6 +77,7 @@ func RegisterAdminRoutes(e *echo.Echo, userHandler *user.Handler, companyHandler companiesGP.DELETE("/:id", companyHandler.Delete) companiesGP.PATCH("/:id/status", companyHandler.UpdateStatus) companiesGP.PATCH("/:id/verification", companyHandler.UpdateVerification) + companiesGP.POST("/:id/documents", companyHandler.UploadDocuments) } // Admin Categories Routes @@ -204,6 +205,7 @@ func RegisterAdminRoutes(e *echo.Echo, userHandler *user.Handler, companyHandler { filesGP.Use(userHandler.AuthMiddleware()) filesGP.POST("/upload", fileStoreHandler.Upload) + filesGP.POST("/upload/batch", fileStoreHandler.UploadBatch) filesGP.GET("", fileStoreHandler.ListFiles) filesGP.GET("/:file_id/info", fileStoreHandler.GetInfo) filesGP.GET("/:file_id/download", fileStoreHandler.Download) @@ -333,6 +335,7 @@ func RegisterPublicRoutes(e *echo.Echo, customerHandler *customer.Handler, tende { publicFilesGP.Use(customerHandler.AuthMiddleware()) publicFilesGP.POST("/upload", fileStoreHandler.Upload) + publicFilesGP.POST("/upload/batch", fileStoreHandler.UploadBatch) publicFilesGP.GET("", fileStoreHandler.ListFiles) publicFilesGP.GET("/:file_id/info", fileStoreHandler.GetInfo) publicFilesGP.GET("/:file_id/download", fileStoreHandler.Download) diff --git a/internal/company/documents.go b/internal/company/documents.go new file mode 100644 index 0000000..4ce3fef --- /dev/null +++ b/internal/company/documents.go @@ -0,0 +1,127 @@ +package company + +import ( + "context" + "errors" + "mime/multipart" + + "tm/pkg/filestore" +) + +const ( + companyDocumentCategory = "company_document" + maxCompanyDocumentsUpload = 20 +) + +// UploadDocumentsResponse is returned after uploading company documents. +type UploadDocumentsResponse struct { + DocumentFileIDs []string `json:"document_file_ids"` + Uploaded []filestore.UploadedFileResult `json:"uploaded"` + Failed []filestore.FailedUploadResult `json:"failed,omitempty"` +} + +// UploadDocuments uploads one or more files and appends their IDs to the company. +func (s *companyService) UploadDocuments(ctx context.Context, companyID, uploadedBy string, files []*multipart.FileHeader) (*UploadDocumentsResponse, error) { + if s.fileStore == nil { + return nil, errors.New("file storage is not configured") + } + if len(files) == 0 { + return nil, errors.New("no files provided") + } + if len(files) > maxCompanyDocumentsUpload { + return nil, errors.New("too many files in request") + } + + company, err := s.repository.GetByID(ctx, companyID) + if err != nil { + return nil, errors.New("company not found") + } + + opts := filestore.UploadOptions{ + UploadedBy: uploadedBy, + Category: companyDocumentCategory, + MaxSize: filestore.MaxUploadSize, + Extra: map[string]string{ + "company_id": companyID, + }, + } + + uploaded := make([]filestore.UploadedFileResult, 0, len(files)) + failed := make([]filestore.FailedUploadResult, 0) + newIDs := make([]string, 0, len(files)) + + for _, file := range files { + result, uploadErr := filestore.UploadFromMultipartFile(s.fileStore, file, opts) + if uploadErr != nil { + code := filestore.ErrCodeUploadFailed + var fsErr *filestore.FileStoreError + if errors.As(uploadErr, &fsErr) { + code = fsErr.Code + } + failed = append(failed, filestore.FailedUploadResult{ + Filename: file.Filename, + Error: uploadErr.Error(), + Code: code, + }) + continue + } + uploaded = append(uploaded, result) + newIDs = append(newIDs, result.FileID) + } + + if len(uploaded) == 0 { + s.logger.Warn("Company document upload failed for all files", map[string]interface{}{ + "company_id": companyID, + "file_count": len(files), + }) + return &UploadDocumentsResponse{ + DocumentFileIDs: company.DocumentFileIDs, + Uploaded: uploaded, + Failed: failed, + }, errors.New("all file uploads failed") + } + + company.DocumentFileIDs = mergeDocumentFileIDs(company.DocumentFileIDs, newIDs) + if err := s.repository.Update(ctx, company); err != nil { + s.logger.Error("Failed to save company documents", map[string]interface{}{ + "error": err.Error(), + "company_id": companyID, + }) + return nil, errors.New("failed to update company documents") + } + + s.logger.Info("Company documents uploaded", map[string]interface{}{ + "company_id": companyID, + "uploaded_count": len(uploaded), + "failed_count": len(failed), + "documents_total": len(company.DocumentFileIDs), + }) + + return &UploadDocumentsResponse{ + DocumentFileIDs: company.DocumentFileIDs, + Uploaded: uploaded, + Failed: failed, + }, nil +} + +func mergeDocumentFileIDs(existing, additional []string) []string { + seen := make(map[string]struct{}, len(existing)+len(additional)) + merged := make([]string, 0, len(existing)+len(additional)) + + appendUnique := func(ids []string) { + for _, id := range ids { + if id == "" { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + merged = append(merged, id) + } + } + + appendUnique(existing) + appendUnique(additional) + return merged +} diff --git a/internal/company/form.go b/internal/company/form.go index d2d5226..6b367c0 100644 --- a/internal/company/form.go +++ b/internal/company/form.go @@ -22,9 +22,8 @@ type ( Address *AddressForm `json:"address,omitempty"` // Contact information - Phone *string `json:"phone,omitempty" valid:"optional,length(10|20)" example:"+1234567890"` - Email *string `json:"email,omitempty" valid:"optional,email" example:"info@opplens.com"` - // GridFS document references (uploaded via /api/v1/files/upload or /admin/v1/files/upload) + Phone *string `json:"phone,omitempty" valid:"optional,length(10|20)" example:"+1234567890"` + Email *string `json:"email,omitempty" valid:"optional,email" example:"info@opplens.com"` DocumentFileIDs []string `json:"document_file_ids,omitempty" valid:"optional"` // Tags for categorization and search @@ -188,13 +187,13 @@ func (c *CompanyTags) ToResponse(categories []*CategoryResponse) *CompanyTagsRes // CompanyProfileResponse represents the company profile data sent in API responses type CompanyProfileResponse struct { - ID string `json:"id"` - Name string `json:"name"` - RegistrationNumber string `json:"registration_number"` - Industry string `json:"industry"` - FoundedYear *int `json:"founded_year,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + RegistrationNumber string `json:"registration_number"` + Industry string `json:"industry"` + FoundedYear *int `json:"founded_year,omitempty"` DocumentFileIDs []string `json:"document_file_ids,omitempty"` - CreatedAt int64 `json:"created_at"` + CreatedAt int64 `json:"created_at"` } // ToResponse converts Company to CompanyResponse diff --git a/internal/company/handler.go b/internal/company/handler.go index 6bd3cf2..62b7fb7 100644 --- a/internal/company/handler.go +++ b/internal/company/handler.go @@ -1,6 +1,8 @@ package company import ( + "mime/multipart" + "tm/internal/user" "tm/pkg/logger" "tm/pkg/response" @@ -307,3 +309,70 @@ func (h *Handler) UpdateVerification(c echo.Context) error { "message": "Company verification updated successfully", }, "Company verification updated successfully") } + +// UploadDocuments uploads multiple documents for a company (Web Panel) +// @Summary Upload company documents +// @Description Upload one or more files and attach them to the company (max 20 files per request). Use form field "files" for multiple files. +// @Tags Admin-Companies +// @Accept multipart/form-data +// @Produce json +// @Param id path string true "Company ID" +// @Param files formData file true "Files to upload (repeat field for multiple files)" +// @Success 200 {object} response.APIResponse{data=UploadDocumentsResponse} "Documents uploaded successfully" +// @Failure 400 {object} response.APIResponse "Bad request - No files or all uploads failed" +// @Failure 404 {object} response.APIResponse "Not found - Company not found" +// @Failure 422 {object} response.APIResponse "Validation error - Too many files" +// @Failure 500 {object} response.APIResponse "Internal server error" +// @Security BearerAuth +// @Router /admin/v1/companies/{id}/documents [post] +func (h *Handler) UploadDocuments(c echo.Context) error { + id := c.Param("id") + userID, err := user.GetUserIDFromContext(c) + if err != nil { + return response.Unauthorized(c, "User not authenticated") + } + + form, err := c.MultipartForm() + if err != nil { + return response.ValidationError(c, "Invalid multipart form", err.Error()) + } + + files := collectCompanyUploadFiles(form) + if len(files) == 0 { + return response.BadRequest(c, "No files provided", "Use form field \"files\" with one or more files") + } + if len(files) > maxCompanyDocumentsUpload { + return response.ValidationError(c, "Too many files", "Maximum 20 files per request") + } + + result, err := h.service.UploadDocuments(c.Request().Context(), id, userID, files) + if err != nil { + switch err.Error() { + case "company not found": + return response.NotFound(c, "Company not found") + case "no files provided", "too many files in request": + return response.BadRequest(c, err.Error(), "") + case "all file uploads failed": + return response.BadRequest(c, "All file uploads failed", "") + default: + return response.InternalServerError(c, "Failed to upload company documents") + } + } + + message := "Company documents uploaded successfully" + if len(result.Failed) > 0 { + message = "Some company documents uploaded successfully" + } + + return response.Success(c, result, message) +} + +func collectCompanyUploadFiles(form *multipart.Form) []*multipart.FileHeader { + if form == nil { + return nil + } + if files := form.File["files"]; len(files) > 0 { + return files + } + return form.File["file"] +} diff --git a/internal/company/service.go b/internal/company/service.go index 7428de9..9590b88 100644 --- a/internal/company/service.go +++ b/internal/company/service.go @@ -4,7 +4,10 @@ import ( "context" "errors" + "mime/multipart" + "tm/internal/company_category" + "tm/pkg/filestore" "tm/pkg/logger" "tm/pkg/response" ) @@ -28,20 +31,25 @@ type Service interface { // Get my company GetMyCompany(ctx context.Context, id string) (*CompanyProfileResponse, error) + + // UploadDocuments uploads files and attaches them to a company + UploadDocuments(ctx context.Context, companyID, uploadedBy string, files []*multipart.FileHeader) (*UploadDocumentsResponse, error) } // companyService implements the Service interface type companyService struct { repository Repository categoryService company_category.Service + fileStore filestore.FileStoreService logger logger.Logger } // NewService creates a new company service -func NewService(repository Repository, categoryService company_category.Service, logger logger.Logger) Service { +func NewService(repository Repository, categoryService company_category.Service, fileStore filestore.FileStoreService, logger logger.Logger) Service { return &companyService{ repository: repository, categoryService: categoryService, + fileStore: fileStore, logger: logger, } } diff --git a/pkg/filestore/handler.go b/pkg/filestore/handler.go index 56810ce..1166ca4 100644 --- a/pkg/filestore/handler.go +++ b/pkg/filestore/handler.go @@ -1,11 +1,9 @@ package filestore import ( - "mime" + "errors" "net/http" - "path/filepath" "strconv" - "strings" "time" "tm/pkg/logger" @@ -36,6 +34,13 @@ type UploadResponse struct { Message string `json:"message"` } +// BatchUploadResponse represents the response for a multi-file upload +type BatchUploadResponse struct { + Uploaded []UploadResponse `json:"uploaded"` + Failed []FailedUploadResult `json:"failed,omitempty"` + Message string `json:"message"` +} + // FileInfoResponse represents file information in API response type FileInfoResponse struct { FileID string `json:"file_id"` @@ -81,7 +86,6 @@ type ErrorResponse struct { // @Failure 500 {object} ErrorResponse // @Router /api/v1/files/upload [post] func (h *Handler) Upload(c echo.Context) error { - // Get file from request file, err := c.FormFile("file") if err != nil { h.logger.Warn("No file provided in upload request", map[string]interface{}{ @@ -94,25 +98,6 @@ func (h *Handler) Upload(c echo.Context) error { }) } - // Open uploaded file - src, err := file.Open() - if err != nil { - h.logger.Error("Failed to open uploaded file", map[string]interface{}{ - "filename": file.Filename, - "error": err.Error(), - }) - return c.JSON(http.StatusInternalServerError, ErrorResponse{ - Error: "Failed to open file", - Code: ErrCodeUploadFailed, - Message: "Unable to read the uploaded file", - }) - } - defer src.Close() - - // Get form data - category := c.FormValue("category") - tags := c.Request().Form["tags"] - description := c.FormValue("description") uploadedBy := h.getUploaderID(c) if uploadedBy == "" { return c.JSON(http.StatusUnauthorized, ErrorResponse{ @@ -122,34 +107,135 @@ func (h *Handler) Upload(c echo.Context) error { }) } - // Detect content type if not provided - contentType := file.Header.Get("Content-Type") - if contentType == "" { - ext := filepath.Ext(file.Filename) - if detected := mime.TypeByExtension(ext); detected != "" { - contentType = detected - } else { - contentType = "application/octet-stream" + opts := h.buildUploadOptions(c, uploadedBy) + result, err := UploadFromMultipartFile(h.service, file, opts) + if err != nil { + return h.writeUploadError(c, file.Filename, err) + } + + return c.JSON(http.StatusOK, UploadResponse{ + FileID: result.FileID, + Filename: result.Filename, + ContentType: result.ContentType, + Size: result.Size, + Message: "File uploaded successfully", + }) +} + +// UploadBatch handles multi-file upload in a single request +// @Summary Upload multiple files +// @Description Upload up to 20 files to GridFS storage in one request. Use form field "files" (repeat for each file) or a single "file". +// @Tags Files +// @Accept multipart/form-data +// @Produce json +// @Param files formData file true "Files to upload" +// @Param category formData string false "File category" +// @Param tags formData []string false "File tags" +// @Param description formData string false "File description" +// @Success 200 {object} BatchUploadResponse +// @Failure 400 {object} ErrorResponse +// @Failure 401 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /api/v1/files/upload/batch [post] +func (h *Handler) UploadBatch(c echo.Context) error { + uploadedBy := h.getUploaderID(c) + if uploadedBy == "" { + return c.JSON(http.StatusUnauthorized, ErrorResponse{ + Error: "Unauthorized", + Code: ErrCodeAccessDenied, + Message: "Authenticated user not found in request context", + }) + } + + form, err := c.MultipartForm() + if err != nil { + return c.JSON(http.StatusBadRequest, ErrorResponse{ + Error: "Invalid multipart form", + Code: ErrCodeInvalidInput, + Message: err.Error(), + }) + } + + files := CollectMultipartFiles(form) + if len(files) == 0 { + return c.JSON(http.StatusBadRequest, ErrorResponse{ + Error: "No files provided", + Code: ErrCodeInvalidInput, + Message: "Use form field \"files\" with one or more files", + }) + } + if len(files) > MaxBatchUploadFiles { + return c.JSON(http.StatusBadRequest, ErrorResponse{ + Error: "Too many files", + Code: ErrCodeInvalidInput, + Message: "Maximum 20 files per request", + }) + } + + opts := h.buildUploadOptions(c, uploadedBy) + results, failed := UploadManyFromMultipart(h.service, files, opts) + if len(results) == 0 { + return c.JSON(http.StatusBadRequest, ErrorResponse{ + Error: "All uploads failed", + Code: ErrCodeUploadFailed, + Message: "No files were uploaded successfully", + }) + } + + uploaded := make([]UploadResponse, len(results)) + for i, r := range results { + uploaded[i] = UploadResponse{ + FileID: r.FileID, + Filename: r.Filename, + ContentType: r.ContentType, + Size: r.Size, + Message: "File uploaded successfully", } } - contentType = strings.TrimSpace(strings.Split(contentType, ";")[0]) - validator := NewFileValidator(100*1024*1024, SupportedMimeTypes) - if err := validator.ValidateFile(file.Filename, contentType, file.Size); err != nil { - if IsFileTooLarge(err) { - return c.JSON(http.StatusRequestEntityTooLarge, ErrorResponse{ - Error: "File is too large", - Code: ErrCodeFileTooLarge, - Message: "Maximum file size is 100 MB", - }) - } - if IsUnsupportedType(err) { - return c.JSON(http.StatusBadRequest, ErrorResponse{ - Error: "Unsupported file type", - Code: ErrCodeUnsupported, - Message: err.Error(), - }) - } + message := "Files uploaded successfully" + if len(failed) > 0 { + message = "Some files uploaded successfully" + } + + return c.JSON(http.StatusOK, BatchUploadResponse{ + Uploaded: uploaded, + Failed: failed, + Message: message, + }) +} + +func (h *Handler) buildUploadOptions(c echo.Context, uploadedBy string) UploadOptions { + return UploadOptions{ + UploadedBy: uploadedBy, + Category: c.FormValue("category"), + Tags: c.Request().Form["tags"], + Description: c.FormValue("description"), + MaxSize: MaxUploadSize, + } +} + +func (h *Handler) writeUploadError(c echo.Context, filename string, err error) error { + if IsFileTooLarge(err) { + h.logger.Warn("File upload failed: file too large", map[string]interface{}{ + "filename": filename, + }) + return c.JSON(http.StatusRequestEntityTooLarge, ErrorResponse{ + Error: "File is too large", + Code: ErrCodeFileTooLarge, + Message: "Maximum file size is 100 MB", + }) + } + if IsUnsupportedType(err) { + return c.JSON(http.StatusBadRequest, ErrorResponse{ + Error: "Unsupported file type", + Code: ErrCodeUnsupported, + Message: err.Error(), + }) + } + + var fsErr *FileStoreError + if errors.As(err, &fsErr) && fsErr.Code == ErrCodeInvalidInput { return c.JSON(http.StatusBadRequest, ErrorResponse{ Error: "Invalid file", Code: ErrCodeInvalidInput, @@ -157,49 +243,14 @@ func (h *Handler) Upload(c echo.Context) error { }) } - // Create upload options - opts := UploadOptions{ - Filename: file.Filename, - ContentType: contentType, - UploadedBy: uploadedBy, - Category: category, - Tags: tags, - Description: description, - MaxSize: 100 * 1024 * 1024, // 100 MB max - } - - // Upload file - fileID, err := h.service.Upload("", file.Filename, src, opts) - if err != nil { - if IsFileTooLarge(err) { - h.logger.Warn("File upload failed: file too large", map[string]interface{}{ - "filename": file.Filename, - "size": file.Size, - }) - return c.JSON(http.StatusRequestEntityTooLarge, ErrorResponse{ - Error: "File is too large", - Code: ErrCodeFileTooLarge, - Message: "Maximum file size is 100 MB", - }) - } - - h.logger.Error("File upload failed", map[string]interface{}{ - "filename": file.Filename, - "error": err.Error(), - }) - return c.JSON(http.StatusInternalServerError, ErrorResponse{ - Error: "Upload failed", - Code: ErrCodeUploadFailed, - Message: "Failed to upload file to storage", - }) - } - - return c.JSON(http.StatusOK, UploadResponse{ - FileID: fileID, - Filename: file.Filename, - ContentType: contentType, - Size: file.Size, - Message: "File uploaded successfully", + h.logger.Error("File upload failed", map[string]interface{}{ + "filename": filename, + "error": err.Error(), + }) + return c.JSON(http.StatusInternalServerError, ErrorResponse{ + Error: "Upload failed", + Code: ErrCodeUploadFailed, + Message: "Failed to upload file to storage", }) } diff --git a/pkg/filestore/upload_helper.go b/pkg/filestore/upload_helper.go new file mode 100644 index 0000000..ff9c201 --- /dev/null +++ b/pkg/filestore/upload_helper.go @@ -0,0 +1,128 @@ +package filestore + +import ( + "errors" + "mime" + "mime/multipart" + "path/filepath" + "strings" +) + +const ( + // MaxUploadSize is the maximum allowed size per file (100 MB). + MaxUploadSize = 100 * 1024 * 1024 + // MaxBatchUploadFiles is the maximum number of files per batch request. + MaxBatchUploadFiles = 20 +) + +// UploadedFileResult holds metadata for a successfully stored file. +type UploadedFileResult struct { + FileID string `json:"file_id"` + Filename string `json:"filename"` + ContentType string `json:"content_type"` + Size int64 `json:"size"` +} + +// FailedUploadResult describes a file that could not be uploaded. +type FailedUploadResult struct { + Filename string `json:"filename"` + Error string `json:"error"` + Code string `json:"code,omitempty"` +} + +// UploadFromMultipartFile validates and stores a single multipart file. +func UploadFromMultipartFile(service FileStoreService, file *multipart.FileHeader, opts UploadOptions) (UploadedFileResult, error) { + if file == nil { + return UploadedFileResult{}, NewError(ErrCodeInvalidInput, "file is required") + } + + contentType := detectContentType(file) + + validator := NewFileValidator(MaxUploadSize, SupportedMimeTypes) + if err := validator.ValidateFile(file.Filename, contentType, file.Size); err != nil { + return UploadedFileResult{}, err + } + + src, err := file.Open() + if err != nil { + return UploadedFileResult{}, NewErrorWithCause(ErrCodeUploadFailed, "failed to open uploaded file", err) + } + defer src.Close() + + if opts.Filename == "" { + opts.Filename = file.Filename + } + if opts.ContentType == "" { + opts.ContentType = contentType + } + if opts.MaxSize == 0 { + opts.MaxSize = MaxUploadSize + } + + fileID, err := service.Upload("", file.Filename, src, opts) + if err != nil { + return UploadedFileResult{}, err + } + + return UploadedFileResult{ + FileID: fileID, + Filename: file.Filename, + ContentType: contentType, + Size: file.Size, + }, nil +} + +// UploadManyFromMultipart uploads multiple files and returns successes and failures. +func UploadManyFromMultipart(service FileStoreService, files []*multipart.FileHeader, opts UploadOptions) ([]UploadedFileResult, []FailedUploadResult) { + uploaded := make([]UploadedFileResult, 0, len(files)) + failed := make([]FailedUploadResult, 0) + + for _, file := range files { + result, err := UploadFromMultipartFile(service, file, opts) + if err != nil { + code := ErrCodeUploadFailed + var fsErr *FileStoreError + if errors.As(err, &fsErr) { + code = fsErr.Code + } + failed = append(failed, FailedUploadResult{ + Filename: file.Filename, + Error: err.Error(), + Code: code, + }) + continue + } + uploaded = append(uploaded, result) + } + + return uploaded, failed +} + +func detectContentType(file *multipart.FileHeader) string { + contentType := file.Header.Get("Content-Type") + if contentType == "" { + ext := filepath.Ext(file.Filename) + if detected := mime.TypeByExtension(ext); detected != "" { + contentType = detected + } else { + contentType = "application/octet-stream" + } + } + return strings.TrimSpace(strings.Split(contentType, ";")[0]) +} + +// CollectMultipartFiles returns file headers from the "files" field, or a single "file" if present. +func CollectMultipartFiles(form *multipart.Form) []*multipart.FileHeader { + if form == nil { + return nil + } + + files := form.File["files"] + if len(files) > 0 { + return files + } + if file := form.File["file"]; len(file) > 0 { + return file + } + return nil +}