Merge branch 'develop' into sort
This commit is contained in:
+1
-1
@@ -210,7 +210,7 @@ func main() {
|
|||||||
auditLogger := audit.NewLogger(logger)
|
auditLogger := audit.NewLogger(logger)
|
||||||
userService := user.NewService(userRepository, logger, userAuthService, userValidator, notificationSDK, redisClient, auditLogger)
|
userService := user.NewService(userRepository, logger, userAuthService, userValidator, notificationSDK, redisClient, auditLogger)
|
||||||
categoryService := company_category.NewService(categoryRepository, logger)
|
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)
|
customerService := customer.NewService(customerRepository, logger, customerAuthService, companyService, redisClient, notificationSDK, customerValidator, auditLogger)
|
||||||
tenderService := tender.NewService(tenderRepository, companyService, customerService, logger, aiSummarizerClient, aiSummarizerStorage, conf.AISummarizer.DefaultLanguage)
|
tenderService := tender.NewService(tenderRepository, companyService, customerService, logger, aiSummarizerClient, aiSummarizerStorage, conf.AISummarizer.DefaultLanguage)
|
||||||
feedbackService := feedback.NewService(feedbackRepo, tenderService, companyService, logger)
|
feedbackService := feedback.NewService(feedbackRepo, tenderService, companyService, logger)
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ func RegisterAdminRoutes(e *echo.Echo, userHandler *user.Handler, companyHandler
|
|||||||
companiesGP.DELETE("/:id", companyHandler.Delete)
|
companiesGP.DELETE("/:id", companyHandler.Delete)
|
||||||
companiesGP.PATCH("/:id/status", companyHandler.UpdateStatus)
|
companiesGP.PATCH("/:id/status", companyHandler.UpdateStatus)
|
||||||
companiesGP.PATCH("/:id/verification", companyHandler.UpdateVerification)
|
companiesGP.PATCH("/:id/verification", companyHandler.UpdateVerification)
|
||||||
|
companiesGP.POST("/:id/documents", companyHandler.UploadDocuments)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Admin Categories Routes
|
// Admin Categories Routes
|
||||||
@@ -204,6 +205,7 @@ func RegisterAdminRoutes(e *echo.Echo, userHandler *user.Handler, companyHandler
|
|||||||
{
|
{
|
||||||
filesGP.Use(userHandler.AuthMiddleware())
|
filesGP.Use(userHandler.AuthMiddleware())
|
||||||
filesGP.POST("/upload", fileStoreHandler.Upload)
|
filesGP.POST("/upload", fileStoreHandler.Upload)
|
||||||
|
filesGP.POST("/upload/batch", fileStoreHandler.UploadBatch)
|
||||||
filesGP.GET("", fileStoreHandler.ListFiles)
|
filesGP.GET("", fileStoreHandler.ListFiles)
|
||||||
filesGP.GET("/:file_id/info", fileStoreHandler.GetInfo)
|
filesGP.GET("/:file_id/info", fileStoreHandler.GetInfo)
|
||||||
filesGP.GET("/:file_id/download", fileStoreHandler.Download)
|
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.Use(customerHandler.AuthMiddleware())
|
||||||
publicFilesGP.POST("/upload", fileStoreHandler.Upload)
|
publicFilesGP.POST("/upload", fileStoreHandler.Upload)
|
||||||
|
publicFilesGP.POST("/upload/batch", fileStoreHandler.UploadBatch)
|
||||||
publicFilesGP.GET("", fileStoreHandler.ListFiles)
|
publicFilesGP.GET("", fileStoreHandler.ListFiles)
|
||||||
publicFilesGP.GET("/:file_id/info", fileStoreHandler.GetInfo)
|
publicFilesGP.GET("/:file_id/info", fileStoreHandler.GetInfo)
|
||||||
publicFilesGP.GET("/:file_id/download", fileStoreHandler.Download)
|
publicFilesGP.GET("/:file_id/download", fileStoreHandler.Download)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -24,7 +24,6 @@ type (
|
|||||||
// Contact information
|
// Contact information
|
||||||
Phone *string `json:"phone,omitempty" valid:"optional,length(10|20)" example:"+1234567890"`
|
Phone *string `json:"phone,omitempty" valid:"optional,length(10|20)" example:"+1234567890"`
|
||||||
Email *string `json:"email,omitempty" valid:"optional,email" example:"info@opplens.com"`
|
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)
|
|
||||||
DocumentFileIDs []string `json:"document_file_ids,omitempty" valid:"optional"`
|
DocumentFileIDs []string `json:"document_file_ids,omitempty" valid:"optional"`
|
||||||
|
|
||||||
// Tags for categorization and search
|
// Tags for categorization and search
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package company
|
package company
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"mime/multipart"
|
||||||
|
|
||||||
"tm/internal/user"
|
"tm/internal/user"
|
||||||
"tm/pkg/logger"
|
"tm/pkg/logger"
|
||||||
"tm/pkg/response"
|
"tm/pkg/response"
|
||||||
@@ -309,3 +311,70 @@ func (h *Handler) UpdateVerification(c echo.Context) error {
|
|||||||
"message": "Company verification updated successfully",
|
"message": "Company verification updated successfully",
|
||||||
}, "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"]
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
|
"mime/multipart"
|
||||||
|
|
||||||
"tm/internal/company_category"
|
"tm/internal/company_category"
|
||||||
|
"tm/pkg/filestore"
|
||||||
"tm/pkg/logger"
|
"tm/pkg/logger"
|
||||||
"tm/pkg/response"
|
"tm/pkg/response"
|
||||||
)
|
)
|
||||||
@@ -28,20 +31,25 @@ type Service interface {
|
|||||||
|
|
||||||
// Get my company
|
// Get my company
|
||||||
GetMyCompany(ctx context.Context, id string) (*CompanyProfileResponse, error)
|
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
|
// companyService implements the Service interface
|
||||||
type companyService struct {
|
type companyService struct {
|
||||||
repository Repository
|
repository Repository
|
||||||
categoryService company_category.Service
|
categoryService company_category.Service
|
||||||
|
fileStore filestore.FileStoreService
|
||||||
logger logger.Logger
|
logger logger.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService creates a new company service
|
// 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{
|
return &companyService{
|
||||||
repository: repository,
|
repository: repository,
|
||||||
categoryService: categoryService,
|
categoryService: categoryService,
|
||||||
|
fileStore: fileStore,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -198,6 +198,9 @@ func (r *feedbackRepository) GetByTenderIDForCustomer(ctx context.Context, tende
|
|||||||
|
|
||||||
result, err := r.repo.FindOne(ctx, filter)
|
result, err := r.repo.FindOne(ctx, filter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, orm.ErrDocumentNotFound) {
|
||||||
|
return nil, ErrFeedbackNotFound
|
||||||
|
}
|
||||||
r.logger.Error("Failed to get feedback by tender ID and customer ID", map[string]interface{}{
|
r.logger.Error("Failed to get feedback by tender ID and customer ID", map[string]interface{}{
|
||||||
"tender_id": tenderID,
|
"tender_id": tenderID,
|
||||||
"customer_id": customerID,
|
"customer_id": customerID,
|
||||||
|
|||||||
@@ -187,11 +187,13 @@ func (s *feedbackService) GetByTenderID(ctx context.Context, tenderID, companyID
|
|||||||
func (s *feedbackService) GetByTenderIDForCustomer(ctx context.Context, tenderID, customerID string) (*FeedbackResponse, error) {
|
func (s *feedbackService) GetByTenderIDForCustomer(ctx context.Context, tenderID, customerID string) (*FeedbackResponse, error) {
|
||||||
feedback, err := s.feedbackRepo.GetByTenderIDForCustomer(ctx, tenderID, customerID)
|
feedback, err := s.feedbackRepo.GetByTenderIDForCustomer(ctx, tenderID, customerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if !errors.Is(err, ErrFeedbackNotFound) {
|
||||||
s.logger.Error("Failed to get feedback by tender ID for customer", map[string]interface{}{
|
s.logger.Error("Failed to get feedback by tender ID for customer", map[string]interface{}{
|
||||||
"tender_id": tenderID,
|
"tender_id": tenderID,
|
||||||
"customer_id": customerID,
|
"customer_id": customerID,
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -447,6 +447,9 @@ func (h *TenderHandler) GetDocuments(c echo.Context) error {
|
|||||||
if errors.Is(err, orm.ErrDocumentNotFound) {
|
if errors.Is(err, orm.ErrDocumentNotFound) {
|
||||||
return response.NotFound(c, "Tender not found")
|
return response.NotFound(c, "Tender not found")
|
||||||
}
|
}
|
||||||
|
if err.Error() == "tender not found" {
|
||||||
|
return response.NotFound(c, "Tender not found")
|
||||||
|
}
|
||||||
return response.InternalServerError(c, "Failed to retrieve tender documents")
|
return response.InternalServerError(c, "Failed to retrieve tender documents")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -478,6 +481,9 @@ func (h *TenderHandler) DownloadDocuments(c echo.Context) error {
|
|||||||
|
|
||||||
download, err := h.service.DownloadDocuments(c.Request().Context(), id)
|
download, err := h.service.DownloadDocuments(c.Request().Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if err.Error() == "tender not found" {
|
||||||
|
return response.NotFound(c, "Tender not found")
|
||||||
|
}
|
||||||
if errors.Is(err, ErrTenderDocumentNotFound) {
|
if errors.Is(err, ErrTenderDocumentNotFound) {
|
||||||
return response.NotFound(c, "Tender documents not found")
|
return response.NotFound(c, "Tender documents not found")
|
||||||
}
|
}
|
||||||
@@ -502,6 +508,9 @@ func (h *TenderHandler) DownloadDocuments(c echo.Context) error {
|
|||||||
func (h *TenderHandler) streamDocumentDownload(c echo.Context, tenderID, filename string) error {
|
func (h *TenderHandler) streamDocumentDownload(c echo.Context, tenderID, filename string) error {
|
||||||
download, err := h.service.DownloadDocument(c.Request().Context(), tenderID, filename)
|
download, err := h.service.DownloadDocument(c.Request().Context(), tenderID, filename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if err.Error() == "tender not found" {
|
||||||
|
return response.NotFound(c, "Tender not found")
|
||||||
|
}
|
||||||
if errors.Is(err, ErrTenderDocumentNotFound) {
|
if errors.Is(err, ErrTenderDocumentNotFound) {
|
||||||
return response.NotFound(c, "Tender document not found")
|
return response.NotFound(c, "Tender document not found")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -498,6 +498,9 @@ func (s *tenderService) GetDocuments(ctx context.Context, id string) (*TenderDoc
|
|||||||
})
|
})
|
||||||
return nil, fmt.Errorf("failed to get tender: %w", err)
|
return nil, fmt.Errorf("failed to get tender: %w", err)
|
||||||
}
|
}
|
||||||
|
if tender == nil {
|
||||||
|
return nil, fmt.Errorf("tender not found")
|
||||||
|
}
|
||||||
|
|
||||||
empty := func() *TenderDocumentsResponse {
|
empty := func() *TenderDocumentsResponse {
|
||||||
return &TenderDocumentsResponse{
|
return &TenderDocumentsResponse{
|
||||||
@@ -1082,6 +1085,9 @@ func (s *tenderService) GetAISummary(ctx context.Context, id string) (*AISummary
|
|||||||
})
|
})
|
||||||
return nil, fmt.Errorf("failed to get tender: %w", err)
|
return nil, fmt.Errorf("failed to get tender: %w", err)
|
||||||
}
|
}
|
||||||
|
if t == nil {
|
||||||
|
return nil, fmt.Errorf("tender not found")
|
||||||
|
}
|
||||||
|
|
||||||
noticeID := strings.TrimSpace(t.NoticePublicationID)
|
noticeID := strings.TrimSpace(t.NoticePublicationID)
|
||||||
emptyResp := func(source string) *AISummaryResponse {
|
emptyResp := func(source string) *AISummaryResponse {
|
||||||
|
|||||||
+122
-71
@@ -1,11 +1,9 @@
|
|||||||
package filestore
|
package filestore
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"mime"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"tm/pkg/logger"
|
"tm/pkg/logger"
|
||||||
@@ -36,6 +34,13 @@ type UploadResponse struct {
|
|||||||
Message string `json:"message"`
|
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
|
// FileInfoResponse represents file information in API response
|
||||||
type FileInfoResponse struct {
|
type FileInfoResponse struct {
|
||||||
FileID string `json:"file_id"`
|
FileID string `json:"file_id"`
|
||||||
@@ -81,7 +86,6 @@ type ErrorResponse struct {
|
|||||||
// @Failure 500 {object} ErrorResponse
|
// @Failure 500 {object} ErrorResponse
|
||||||
// @Router /api/v1/files/upload [post]
|
// @Router /api/v1/files/upload [post]
|
||||||
func (h *Handler) Upload(c echo.Context) error {
|
func (h *Handler) Upload(c echo.Context) error {
|
||||||
// Get file from request
|
|
||||||
file, err := c.FormFile("file")
|
file, err := c.FormFile("file")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Warn("No file provided in upload request", map[string]interface{}{
|
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)
|
uploadedBy := h.getUploaderID(c)
|
||||||
if uploadedBy == "" {
|
if uploadedBy == "" {
|
||||||
return c.JSON(http.StatusUnauthorized, ErrorResponse{
|
return c.JSON(http.StatusUnauthorized, ErrorResponse{
|
||||||
@@ -122,21 +107,119 @@ func (h *Handler) Upload(c echo.Context) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect content type if not provided
|
opts := h.buildUploadOptions(c, uploadedBy)
|
||||||
contentType := file.Header.Get("Content-Type")
|
result, err := UploadFromMultipartFile(h.service, file, opts)
|
||||||
if contentType == "" {
|
if err != nil {
|
||||||
ext := filepath.Ext(file.Filename)
|
return h.writeUploadError(c, file.Filename, err)
|
||||||
if detected := mime.TypeByExtension(ext); detected != "" {
|
|
||||||
contentType = detected
|
|
||||||
} else {
|
|
||||||
contentType = "application/octet-stream"
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
contentType = strings.TrimSpace(strings.Split(contentType, ";")[0])
|
|
||||||
|
|
||||||
validator := NewFileValidator(100*1024*1024, SupportedMimeTypes)
|
return c.JSON(http.StatusOK, UploadResponse{
|
||||||
if err := validator.ValidateFile(file.Filename, contentType, file.Size); err != nil {
|
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",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
if IsFileTooLarge(err) {
|
||||||
|
h.logger.Warn("File upload failed: file too large", map[string]interface{}{
|
||||||
|
"filename": filename,
|
||||||
|
})
|
||||||
return c.JSON(http.StatusRequestEntityTooLarge, ErrorResponse{
|
return c.JSON(http.StatusRequestEntityTooLarge, ErrorResponse{
|
||||||
Error: "File is too large",
|
Error: "File is too large",
|
||||||
Code: ErrCodeFileTooLarge,
|
Code: ErrCodeFileTooLarge,
|
||||||
@@ -150,6 +233,9 @@ func (h *Handler) Upload(c echo.Context) error {
|
|||||||
Message: err.Error(),
|
Message: err.Error(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var fsErr *FileStoreError
|
||||||
|
if errors.As(err, &fsErr) && fsErr.Code == ErrCodeInvalidInput {
|
||||||
return c.JSON(http.StatusBadRequest, ErrorResponse{
|
return c.JSON(http.StatusBadRequest, ErrorResponse{
|
||||||
Error: "Invalid file",
|
Error: "Invalid file",
|
||||||
Code: ErrCodeInvalidInput,
|
Code: ErrCodeInvalidInput,
|
||||||
@@ -157,34 +243,8 @@ 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{}{
|
h.logger.Error("File upload failed", map[string]interface{}{
|
||||||
"filename": file.Filename,
|
"filename": filename,
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
return c.JSON(http.StatusInternalServerError, ErrorResponse{
|
return c.JSON(http.StatusInternalServerError, ErrorResponse{
|
||||||
@@ -194,15 +254,6 @@ func (h *Handler) Upload(c echo.Context) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, UploadResponse{
|
|
||||||
FileID: fileID,
|
|
||||||
Filename: file.Filename,
|
|
||||||
ContentType: contentType,
|
|
||||||
Size: file.Size,
|
|
||||||
Message: "File uploaded successfully",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Download handles file download
|
// Download handles file download
|
||||||
// @Summary Download a file
|
// @Summary Download a file
|
||||||
// @Description Download a file by its ID
|
// @Description Download a file by its ID
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user