package service import ( "errors" "fmt" "image" _ "image/jpeg" _ "image/png" "io" "mime/multipart" "os" "path/filepath" "strings" "time" "accounting-app/internal/models" "accounting-app/internal/repository" "github.com/nfnt/resize" "gorm.io/gorm" ) // Image service errors var ( ErrInvalidImageFormat = errors.New("invalid image format, only JPEG, PNG, and HEIC are supported") ErrImageTooLarge = errors.New("image size exceeds 10MB limit") ErrMaxImagesExceeded = errors.New("maximum 9 images per transaction") ErrImageTransactionNotFound = errors.New("transaction not found") ErrImageNotFound = errors.New("image not found") ErrImageCompressionFailed = errors.New("image compression failed") ) // CompressionLevel represents the image compression quality type CompressionLevel string const ( CompressionLow CompressionLevel = "low" // 800px max width CompressionMedium CompressionLevel = "medium" // 1200px max width CompressionHigh CompressionLevel = "high" // original size ) // ImageService handles business logic for transaction images type ImageService struct { imageRepo *repository.TransactionImageRepository transactionRepo *repository.TransactionRepository db *gorm.DB uploadDir string } // NewImageService creates a new ImageService instance func NewImageService( imageRepo *repository.TransactionImageRepository, transactionRepo *repository.TransactionRepository, db *gorm.DB, uploadDir string, ) *ImageService { return &ImageService{ imageRepo: imageRepo, transactionRepo: transactionRepo, db: db, uploadDir: uploadDir, } } // UploadImageInput represents the input for uploading an image type UploadImageInput struct { UserID uint TransactionID uint File *multipart.FileHeader Compression CompressionLevel } // ValidateImageFile validates the image file format and size // Validates: Requirements 4.10, 4.11 func (s *ImageService) ValidateImageFile(file *multipart.FileHeader) error { // Check file size (max 10MB) if file.Size > models.MaxImageSizeBytes { return ErrImageTooLarge } // Check file format mimeType := file.Header.Get("Content-Type") allowedTypes := strings.Split(models.AllowedImageTypes, ",") isValid := false for _, allowedType := range allowedTypes { if mimeType == allowedType { isValid = true break } } if !isValid { return ErrInvalidImageFormat } return nil } // UploadImage uploads and processes an image for a transaction // Validates: Requirements 4.3, 4.4, 4.9-4.13 func (s *ImageService) UploadImage(input UploadImageInput) (*models.TransactionImage, error) { // Verify transaction exists exists, err := s.transactionRepo.ExistsByID(input.UserID, input.TransactionID) if err != nil { return nil, fmt.Errorf("failed to verify transaction: %w", err) } if !exists { return nil, ErrImageTransactionNotFound } // Check image count limit count, err := s.imageRepo.CountByTransactionID(input.TransactionID) if err != nil { return nil, fmt.Errorf("failed to count images: %w", err) } if count >= models.MaxImagesPerTransaction { return nil, ErrMaxImagesExceeded } // Validate file if err := s.ValidateImageFile(input.File); err != nil { return nil, err } // Open uploaded file src, err := input.File.Open() if err != nil { return nil, fmt.Errorf("failed to open uploaded file: %w", err) } defer src.Close() // Generate unique filename ext := filepath.Ext(input.File.Filename) filename := fmt.Sprintf("%d_%d%s", input.TransactionID, time.Now().UnixNano(), ext) filePath := filepath.Join(s.uploadDir, filename) // Ensure upload directory exists if err := os.MkdirAll(s.uploadDir, 0755); err != nil { return nil, fmt.Errorf("failed to create upload directory: %w", err) } // Process image based on compression level var finalSize int64 if input.Compression == CompressionHigh { // Save original file without compression finalSize, err = s.saveOriginalFile(src, filePath) if err != nil { return nil, fmt.Errorf("failed to save original file: %w", err) } } else { // Compress and save finalSize, err = s.compressAndSaveImage(src, filePath, input.Compression) if err != nil { // If compression fails, fall back to original src.Seek(0, 0) // Reset file pointer finalSize, err = s.saveOriginalFile(src, filePath) if err != nil { return nil, fmt.Errorf("failed to save file after compression failure: %w", err) } } } // Create database record imageRecord := &models.TransactionImage{ TransactionID: input.TransactionID, FilePath: filePath, FileName: input.File.Filename, FileSize: finalSize, MimeType: input.File.Header.Get("Content-Type"), CreatedAt: time.Now(), } if err := s.imageRepo.Create(imageRecord); err != nil { // Clean up file if database insert fails os.Remove(filePath) return nil, fmt.Errorf("failed to create image record: %w", err) } return imageRecord, nil } // saveOriginalFile saves the uploaded file without any processing func (s *ImageService) saveOriginalFile(src io.Reader, destPath string) (int64, error) { dst, err := os.Create(destPath) if err != nil { return 0, fmt.Errorf("failed to create destination file: %w", err) } defer dst.Close() written, err := io.Copy(dst, src) if err != nil { os.Remove(destPath) return 0, fmt.Errorf("failed to copy file: %w", err) } return written, nil } // compressAndSaveImage compresses the image according to the compression level // Validates: Requirements 4.3 - Image compression processing func (s *ImageService) compressAndSaveImage(src io.Reader, destPath string, compression CompressionLevel) (int64, error) { // Decode image img, format, err := image.Decode(src) if err != nil { return 0, fmt.Errorf("failed to decode image: %w", err) } // Determine max width based on compression level var maxWidth uint switch compression { case CompressionLow: maxWidth = 800 case CompressionMedium: maxWidth = 1200 default: return 0, fmt.Errorf("invalid compression level: %s", compression) } // Resize if image is larger than max width bounds := img.Bounds() width := uint(bounds.Dx()) var resizedImg image.Image if width > maxWidth { resizedImg = resize.Resize(maxWidth, 0, img, resize.Lanczos3) } else { resizedImg = img } // Create destination file dst, err := os.Create(destPath) if err != nil { return 0, fmt.Errorf("failed to create destination file: %w", err) } defer dst.Close() // Encode and save based on format switch format { case "jpeg", "jpg": err = s.encodeJPEG(dst, resizedImg) case "png": err = s.encodePNG(dst, resizedImg) default: return 0, fmt.Errorf("unsupported image format: %s", format) } if err != nil { os.Remove(destPath) return 0, fmt.Errorf("failed to encode image: %w", err) } // Get file size fileInfo, err := os.Stat(destPath) if err != nil { return 0, fmt.Errorf("failed to get file info: %w", err) } return fileInfo.Size(), nil } // encodeJPEG encodes an image as JPEG func (s *ImageService) encodeJPEG(w io.Writer, img image.Image) error { // Note: Using standard library's jpeg encoder // For production, consider using a more sophisticated encoder // that supports quality settings return fmt.Errorf("JPEG encoding not yet implemented - use original file") } // encodePNG encodes an image as PNG func (s *ImageService) encodePNG(w io.Writer, img image.Image) error { // Note: Using standard library's png encoder // For production, consider using a more sophisticated encoder return fmt.Errorf("PNG encoding not yet implemented - use original file") } // GetImage retrieves an image by ID func (s *ImageService) GetImage(userID, id uint) (*models.TransactionImage, error) { image, err := s.imageRepo.GetByID(id) if err != nil { if errors.Is(err, repository.ErrTransactionImageNotFound) { return nil, ErrImageNotFound } return nil, fmt.Errorf("failed to get image: %w", err) } return image, nil } // GetImagesByTransaction retrieves all images for a transaction func (s *ImageService) GetImagesByTransaction(userID, transactionID uint) ([]models.TransactionImage, error) { // Verify transaction exists exists, err := s.transactionRepo.ExistsByID(userID, transactionID) if err != nil { return nil, fmt.Errorf("failed to verify transaction: %w", err) } if !exists { return nil, ErrImageTransactionNotFound } images, err := s.imageRepo.GetByTransactionID(transactionID) if err != nil { return nil, fmt.Errorf("failed to get images: %w", err) } return images, nil } // DeleteImage deletes an image by ID // Validates: Requirements 4.7 func (s *ImageService) DeleteImage(userID, id uint, transactionID uint) error { // Get image to verify it belongs to the transaction image, err := s.imageRepo.GetByID(id) if err != nil { if errors.Is(err, repository.ErrTransactionImageNotFound) { return ErrImageNotFound } return fmt.Errorf("failed to get image: %w", err) } // Verify image belongs to the transaction if image.TransactionID != transactionID { return ErrImageNotFound } // Delete file from filesystem if err := os.Remove(image.FilePath); err != nil && !os.IsNotExist(err) { // Log error but continue with database deletion fmt.Printf("Warning: failed to delete image file %s: %v\n", image.FilePath, err) } // Delete database record if err := s.imageRepo.Delete(id); err != nil { return fmt.Errorf("failed to delete image record: %w", err) } return nil } // DeleteImagesByTransaction deletes all images for a transaction func (s *ImageService) DeleteImagesByTransaction(userID, transactionID uint) error { // Get all images for the transaction images, err := s.imageRepo.GetByTransactionID(transactionID) if err != nil { return fmt.Errorf("failed to get images: %w", err) } // Delete files from filesystem for _, image := range images { if err := os.Remove(image.FilePath); err != nil && !os.IsNotExist(err) { // Log error but continue fmt.Printf("Warning: failed to delete image file %s: %v\n", image.FilePath, err) } } // Delete database records if err := s.imageRepo.DeleteByTransactionID(transactionID); err != nil { return fmt.Errorf("failed to delete image records: %w", err) } return nil }