Files
tm_back/pkg/filestore/handler.go
T
Mazyar 5fbfcd4149
continuous-integration/drone/push Build is passing
Enhance company document management with file ID sanitization and detachment functionality
- Updated the `UploadDocuments` method to sanitize document file IDs before saving, ensuring only valid references are stored.
- Introduced `DetachDocumentFileID` method in the `companyService` to remove file IDs from all companies referencing a deleted file, improving data integrity.
- Enhanced the `companyRepository` with a new method to handle the removal of document file IDs from the database.
- Updated the `filestore` handler to utilize the new detachment functionality when files are deleted, ensuring consistent state across domain entities.

This update improves the management of document file IDs within the company domain, enhancing data integrity and reference handling.
2026-06-22 22:37:50 +03:30

521 lines
15 KiB
Go

package filestore
import (
"context"
"errors"
"net/http"
"strconv"
"time"
"tm/pkg/logger"
"github.com/labstack/echo/v4"
)
// DocumentReferenceCleaner removes stored file IDs from domain entities when a file is deleted.
type DocumentReferenceCleaner interface {
DetachDocumentFileID(ctx context.Context, fileID string) error
}
// Handler handles HTTP requests for file operations
type Handler struct {
service FileStoreService
logger logger.Logger
referenceCleaner DocumentReferenceCleaner
}
// NewHandler creates a new file handler
func NewHandler(service FileStoreService, logger logger.Logger, referenceCleaner DocumentReferenceCleaner) *Handler {
return &Handler{
service: service,
logger: logger,
referenceCleaner: referenceCleaner,
}
}
// UploadResponse represents the response for a successful upload
type UploadResponse struct {
FileID string `json:"file_id"`
Filename string `json:"filename"`
ContentType string `json:"content_type"`
Size int64 `json:"size"`
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"`
Filename string `json:"filename"`
ContentType string `json:"content_type"`
Size int64 `json:"size"`
UploadedBy string `json:"uploaded_by,omitempty"`
UploadedAt time.Time `json:"uploaded_at"`
Category string `json:"category,omitempty"`
Tags []string `json:"tags,omitempty"`
Description string `json:"description,omitempty"`
Extra map[string]string `json:"extra,omitempty"`
}
// ListFilesResponse represents the response for list files operation
type ListFilesResponse struct {
Files []FileInfoResponse `json:"files"`
Total int64 `json:"total"`
Limit int64 `json:"limit"`
Skip int64 `json:"skip"`
}
// ErrorResponse represents an error response
type ErrorResponse struct {
Error string `json:"error"`
Code string `json:"code,omitempty"`
Message string `json:"message,omitempty"`
}
// Upload handles file upload
// @Summary Upload a file
// @Description Upload a file to GridFS storage
// @Tags Files
// @Accept multipart/form-data
// @Produce json
// @Param file formData file true "File 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} UploadResponse
// @Failure 400 {object} ErrorResponse
// @Failure 413 {object} ErrorResponse "File too large"
// @Failure 500 {object} ErrorResponse
// @Router /api/v1/files/upload [post]
func (h *Handler) Upload(c echo.Context) error {
file, err := c.FormFile("file")
if err != nil {
h.logger.Warn("No file provided in upload request", map[string]interface{}{
"error": err.Error(),
})
return c.JSON(http.StatusBadRequest, ErrorResponse{
Error: "No file provided",
Code: ErrCodeInvalidInput,
Message: "Please provide a file to upload",
})
}
uploadedBy := h.getUploaderID(c)
if uploadedBy == "" {
return c.JSON(http.StatusUnauthorized, ErrorResponse{
Error: "Unauthorized",
Code: ErrCodeAccessDenied,
Message: "Authenticated user not found in request context",
})
}
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",
}
}
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,
Message: err.Error(),
})
}
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",
})
}
// Download handles file download
// @Summary Download a file
// @Description Download a file by its ID
// @Tags Files
// @Produce application/octet-stream
// @Param file_id path string true "File ID"
// @Success 200 {file} bytes
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /api/v1/files/{file_id}/download [get]
func (h *Handler) Download(c echo.Context) error {
fileID := c.Param("file_id")
if fileID == "" {
return c.JSON(http.StatusBadRequest, ErrorResponse{
Error: "File ID is required",
Code: ErrCodeInvalidInput,
Message: "Please provide a valid file ID",
})
}
// Download file
reader, fileInfo, err := h.service.Download(fileID)
if err != nil {
if IsNotFound(err) {
h.logger.Warn("File not found", map[string]interface{}{
"file_id": fileID,
})
return c.JSON(http.StatusNotFound, ErrorResponse{
Error: "File not found",
Code: ErrCodeNotFound,
Message: "The requested file does not exist",
})
}
h.logger.Error("File download failed", map[string]interface{}{
"file_id": fileID,
"error": err.Error(),
})
return c.JSON(http.StatusInternalServerError, ErrorResponse{
Error: "Download failed",
Code: ErrCodeDownloadFailed,
Message: "Failed to download file",
})
}
defer reader.Close()
// Set response headers
c.Response().Header().Set("Content-Type", fileInfo.ContentType)
c.Response().Header().Set("Content-Length", strconv.FormatInt(fileInfo.Size, 10))
c.Response().Header().Set("Content-Disposition", `attachment; filename="`+fileInfo.Filename+`"`)
c.Response().Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
// Write file to response
return c.Stream(http.StatusOK, fileInfo.ContentType, reader)
}
func (h *Handler) getUploaderID(c echo.Context) string {
if userID, ok := c.Get("user_id").(string); ok && userID != "" {
return userID
}
if customerID, ok := c.Get("customer_id").(string); ok && customerID != "" {
return customerID
}
return ""
}
// GetInfo retrieves file information
// @Summary Get file information
// @Description Get metadata for a file by its ID
// @Tags Files
// @Produce json
// @Param file_id path string true "File ID"
// @Success 200 {object} FileInfoResponse
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /api/v1/files/{file_id}/info [get]
func (h *Handler) GetInfo(c echo.Context) error {
fileID := c.Param("file_id")
if fileID == "" {
return c.JSON(http.StatusBadRequest, ErrorResponse{
Error: "File ID is required",
Code: ErrCodeInvalidInput,
Message: "Please provide a valid file ID",
})
}
// Get file info
fileInfo, err := h.service.GetFileInfo(fileID)
if err != nil {
if IsNotFound(err) {
return c.JSON(http.StatusNotFound, ErrorResponse{
Error: "File not found",
Code: ErrCodeNotFound,
Message: "The requested file does not exist",
})
}
h.logger.Error("Failed to get file info", map[string]interface{}{
"file_id": fileID,
"error": err.Error(),
})
return c.JSON(http.StatusInternalServerError, ErrorResponse{
Error: "Failed to get file info",
Code: ErrCodeDownloadFailed,
Message: "Unable to retrieve file information",
})
}
return c.JSON(http.StatusOK, FileInfoResponse{
FileID: fileInfo.FileID,
Filename: fileInfo.Filename,
ContentType: fileInfo.ContentType,
Size: fileInfo.Size,
UploadedBy: fileInfo.UploadedBy,
UploadedAt: fileInfo.UploadedAt,
Category: fileInfo.Category,
Tags: fileInfo.Tags,
Description: fileInfo.Description,
Extra: fileInfo.Extra,
})
}
// Delete removes a file
// @Summary Delete a file
// @Description Delete a file by its ID
// @Tags Files
// @Param file_id path string true "File ID"
// @Success 200 {object} map[string]string
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /api/v1/files/{file_id} [delete]
func (h *Handler) Delete(c echo.Context) error {
fileID := c.Param("file_id")
if fileID == "" {
return c.JSON(http.StatusBadRequest, ErrorResponse{
Error: "File ID is required",
Code: ErrCodeInvalidInput,
Message: "Please provide a valid file ID",
})
}
// Delete file
err := h.service.Delete(fileID)
if err != nil {
if IsNotFound(err) {
return c.JSON(http.StatusNotFound, ErrorResponse{
Error: "File not found",
Code: ErrCodeNotFound,
Message: "The requested file does not exist",
})
}
h.logger.Error("File deletion failed", map[string]interface{}{
"file_id": fileID,
"error": err.Error(),
})
return c.JSON(http.StatusInternalServerError, ErrorResponse{
Error: "Deletion failed",
Code: ErrCodeDeleteFailed,
Message: "Failed to delete file",
})
}
if h.referenceCleaner != nil {
if cleanErr := h.referenceCleaner.DetachDocumentFileID(c.Request().Context(), fileID); cleanErr != nil {
h.logger.Warn("Failed to detach deleted file from domain references", map[string]interface{}{
"file_id": fileID,
"error": cleanErr.Error(),
})
}
}
return c.JSON(http.StatusOK, map[string]string{
"message": "File deleted successfully",
})
}
// ListFiles lists files by category
// @Summary List files
// @Description List files by category with optional filtering
// @Tags Files
// @Produce json
// @Param category query string false "File category"
// @Param tags query []string false "File tags"
// @Param limit query int false "Number of files to return (default 50, max 1000)"
// @Param skip query int false "Number of files to skip (default 0)"
// @Success 200 {object} ListFilesResponse
// @Failure 500 {object} ErrorResponse
// @Router /api/v1/files [get]
func (h *Handler) ListFiles(c echo.Context) error {
category := c.QueryParam("category")
tags := c.QueryParams()["tags"]
limit := int64(50)
skip := int64(0)
// Parse pagination params
if l := c.QueryParam("limit"); l != "" {
if val, err := strconv.ParseInt(l, 10, 64); err != nil {
return c.JSON(http.StatusBadRequest, ErrorResponse{
Error: "Invalid limit parameter",
Code: ErrCodeInvalidInput,
Message: "Limit must be a valid integer",
})
} else {
limit = val
}
}
if s := c.QueryParam("skip"); s != "" {
if val, err := strconv.ParseInt(s, 10, 64); err != nil {
return c.JSON(http.StatusBadRequest, ErrorResponse{
Error: "Invalid skip parameter",
Code: ErrCodeInvalidInput,
Message: "Skip must be a valid integer",
})
} else {
skip = val
}
}
// List files
files, total, err := h.service.ListFiles(category, tags, limit, skip)
if err != nil {
h.logger.Error("Failed to list files", map[string]interface{}{
"error": err.Error(),
})
return c.JSON(http.StatusInternalServerError, ErrorResponse{
Error: "Failed to list files",
Code: ErrCodeDownloadFailed,
Message: "Unable to retrieve file list",
})
}
// Convert to response format
fileResponses := make([]FileInfoResponse, len(files))
for i, f := range files {
fileResponses[i] = FileInfoResponse{
FileID: f.FileID,
Filename: f.Filename,
ContentType: f.ContentType,
Size: f.Size,
UploadedBy: f.UploadedBy,
UploadedAt: f.UploadedAt,
Category: f.Category,
Tags: f.Tags,
Description: f.Description,
Extra: f.Extra,
}
}
return c.JSON(http.StatusOK, ListFilesResponse{
Files: fileResponses,
Total: total,
Limit: limit,
Skip: skip,
})
}