package merch import ( "bytes" "context" "database/sql" "errors" "fmt" "github.com/disintegration/imaging" "github.com/google/uuid" log "github.com/sirupsen/logrus" "image" "image/jpeg" "io" "merch-parser-api/internal/interfaces" is "merch-parser-api/proto/imageStorage" "mime/multipart" "path/filepath" "strings" "time" ) type service struct { repo repository media interfaces.MediaStorage bucketName string expires time.Duration imageStorage is.ImageStorageClient } type serviceDeps struct { repo repository media interfaces.MediaStorage bucketName string expires time.Duration imageStorage is.ImageStorageClient } func newService(deps serviceDeps) *service { return &service{ repo: deps.repo, media: deps.media, bucketName: deps.bucketName, expires: deps.expires, imageStorage: deps.imageStorage, } } type uploadImageParams struct { ctx context.Context src io.Reader imageType string object string quality int } func (s *service) addMerch(payload MerchDTO, userUuid string) error { merchUuid := uuid.NewString() bundle := merchBundle{ &Merch{ CreatedAt: time.Time{}, UpdatedAt: sql.NullTime{Valid: false}, DeletedAt: sql.NullTime{Valid: false}, MerchUuid: merchUuid, UserUuid: userUuid, Name: payload.Name, }, &Surugaya{ DeletedAt: sql.NullTime{}, MerchUuid: merchUuid, Link: payload.OriginSurugaya.Link, }, &Mandarake{ DeletedAt: sql.NullTime{}, MerchUuid: merchUuid, Link: payload.OriginMandarake.Link, }, } return s.repo.addMerch(bundle) } func (s *service) getSingleMerch(userUuid, merchUuid string) (MerchDTO, error) { bundle, err := s.repo.getSingleMerch(userUuid, merchUuid) if err != nil { return MerchDTO{}, err } return MerchDTO{ MerchUuid: bundle.Merch.MerchUuid, Name: bundle.Merch.Name, OriginSurugaya: SurugayaDTO{ Link: bundle.Surugaya.Link, }, OriginMandarake: MandarakeDTO{ Link: bundle.Mandarake.Link, }, }, nil } func (s *service) getAllMerch(userUuid string) ([]ListResponse, error) { return s.repo.getAllMerch(userUuid) } func (s *service) updateMerch(payload UpdateMerchDTO, userUuid string) error { if payload.MerchUuid == "" { return errors.New("no merch uuid provided") } if payload.Origin == "" { return errors.New("no origin provided") } return s.repo.updateMerch(payload, userUuid) } func (s *service) deleteMerch(userUuid, merchUuid string) error { ctx, cancel := context.WithTimeout(context.Background(), s.expires) defer cancel() if err := s.deleteMerchImage(ctx, userUuid, merchUuid); err != nil { return err } return s.repo.deleteMerch(userUuid, merchUuid) } func (s *service) getPrices(userUuid string, days string) ([]PricesResponse, error) { merchList, err := s.repo.getAllUserMerch(userUuid) if err != nil { return nil, err } if len(merchList) == 0 { return nil, errors.New("no merch found") } var response []PricesResponse for _, item := range merchList { response = append(response, PricesResponse{ MerchUuid: item.MerchUuid, Name: item.Name, Origins: []OriginWithPrices{}, }) } pricesList, err := s.repo.getPricesWithDays(userUuid, getPeriod(days)) if err != nil { return nil, err } pricesMap := make(map[string]map[Origin][]PriceEntry) for _, item := range pricesList { if _, ok := pricesMap[item.MerchUuid]; !ok { pricesMap[item.MerchUuid] = make(map[Origin][]PriceEntry) } pricesMap[item.MerchUuid][item.Origin] = append(pricesMap[item.MerchUuid][item.Origin], PriceEntry{ CreatedAt: item.CreatedAt.Unix(), Value: item.Price, }) } for i := range response { originSurugaya := OriginWithPrices{ Origin: surugaya, Prices: pricesMap[response[i].MerchUuid][surugaya], } response[i].Origins = append(response[i].Origins, originSurugaya) originMandarake := OriginWithPrices{ Origin: mandarake, Prices: pricesMap[response[i].MerchUuid][mandarake], } response[i].Origins = append(response[i].Origins, originMandarake) } return response, nil } func (s *service) getDistinctPrices(userUuid, merchUuid, days string) (PricesResponse, error) { result, err := s.repo.getDistinctPrices(userUuid, merchUuid, getPeriod(days)) if err != nil { return PricesResponse{}, err } if result == nil { return PricesResponse{}, errors.New("no prices found") } originSurugaya := OriginWithPrices{ Origin: surugaya, Prices: []PriceEntry{}, } originMandarake := OriginWithPrices{ Origin: mandarake, Prices: []PriceEntry{}, } for _, item := range result { switch item.Origin { case surugaya: originSurugaya.Prices = append(originSurugaya.Prices, PriceEntry{ CreatedAt: item.CreatedAt.Unix(), Value: item.Price, }) case mandarake: originMandarake.Prices = append(originMandarake.Prices, PriceEntry{ CreatedAt: item.CreatedAt.Unix(), Value: item.Price, }) } } return PricesResponse{ MerchUuid: merchUuid, Origins: []OriginWithPrices{originSurugaya, originMandarake}, }, nil } // uploadMerchImage // Deprecated. // Use only with MinIO storage. Use mtUploadMerchImage for merch-tracker images storage. func (s *service) uploadMerchImage(ctx context.Context, userUuid, merchUuid, imageType string, file *multipart.FileHeader) error { exists, err := s.repo.merchRecordExists(userUuid, merchUuid) if err != nil { return err } if !exists { return fmt.Errorf("no merch found for user %s with uuid %s", userUuid, merchUuid) } rawExt := filepath.Ext(file.Filename) if rawExt == "" { return errors.New("no file extension") } ext := strings.ToLower(rawExt[1:]) allowedTypes := map[string]struct{}{"jpeg": {}, "jpg": {}, "png": {}, "gif": {}} if _, ok := allowedTypes[ext]; !ok { return errors.New("invalid file type") } getSrc := func() (io.ReadCloser, error) { f, err := file.Open() if err != nil { log.WithError(err).Error("Merch | Failed to open file") return nil, err } return f, nil } switch imageType { case "thumbnail": src, err := getSrc() if err != nil { return err } return s._uploadToStorage(uploadImageParams{ ctx: ctx, src: src, imageType: "thumbnail", object: fmt.Sprintf("%s/merch/%s/thumbnail.jpg", userUuid, merchUuid), quality: 80, }) case "full": src, err := getSrc() if err != nil { return err } return s._uploadToStorage(uploadImageParams{ ctx: ctx, src: src, imageType: "full", object: fmt.Sprintf("%s/merch/%s/full.jpg", userUuid, merchUuid), quality: 90, }) case "all": src, err := getSrc() if err != nil { return err } if err = s._uploadToStorage(uploadImageParams{ ctx: ctx, src: src, imageType: "thumbnail", object: fmt.Sprintf("%s/merch/%s/thumbnail.jpg", userUuid, merchUuid), quality: 80, }); err != nil { log.WithError(err).Error("Merch | Upload thumbnail and full image") return err } src2, err := getSrc() if err != nil { return err } if err = s._uploadToStorage(uploadImageParams{ ctx: ctx, src: src2, imageType: "full", object: fmt.Sprintf("%s/merch/%s/full.jpg", userUuid, merchUuid), quality: 90, }); err != nil { log.WithError(err).Error("Merch | Upload thumbnail and full image") return err } default: return errors.New("invalid file type") } return nil } // getPublicImageLink // Deprecated. // Use only with MinIO storage. func (s *service) getPublicImageLink(ctx context.Context, userUuid, merchUuid, imageType string) (ImageLink, error) { object, err := s.makeObject(userUuid, merchUuid, imageType) if err != nil { return ImageLink{}, err } link, etag, err := s.media.GetPublicLink(ctx, s.bucketName, object) if err != nil { return ImageLink{}, err } return ImageLink{ Link: link, ETag: etag, }, nil } // getPresignedImageLink // Deprecated. // Use only with MinIO storage. func (s *service) getPresignedImageLink(ctx context.Context, userUuid, merchUuid, imageType string) (ImageLink, error) { exists, err := s.repo.merchRecordExists(userUuid, merchUuid) if err != nil { return ImageLink{}, err } if !exists { return ImageLink{}, fmt.Errorf("no merch found for user %s with uuid %s", userUuid, merchUuid) } object, err := s.makeObject(userUuid, merchUuid, imageType) if err != nil { return ImageLink{}, err } link, err := s.media.GetPresignedLink(ctx, s.bucketName, object, s.expires, nil) if err != nil { return ImageLink{}, err } etag, err := s.media.GetObjectEtag(ctx, s.bucketName, object) if err != nil { return ImageLink{}, err } return ImageLink{ Link: link, ETag: etag, }, nil } // deleteMerchImage // Deprecated. // Use only with MinIO storage. func (s *service) deleteMerchImage(ctx context.Context, userUuid, merchUuid string) error { exists, err := s.repo.merchRecordExists(userUuid, merchUuid) if err != nil { return err } if !exists { return fmt.Errorf("no merch found for user %s with uuid %s", userUuid, merchUuid) } if err = s.media.Delete(ctx, s.bucketName, fmt.Sprintf("%s/merch/%s/thumbnail.jpg", userUuid, merchUuid)); err != nil { return err } if err = s.media.Delete(ctx, s.bucketName, fmt.Sprintf("%s/merch/%s/full.jpg", userUuid, merchUuid)); err != nil { return err } return nil } func (s *service) _uploadToStorage(params uploadImageParams) error { img, _, err := image.Decode(params.src) if err != nil { return fmt.Errorf("failed to decode image: %w", err) } if params.imageType == "thumbnail" { img = imaging.Resize(img, 300, 300, imaging.Lanczos) } var buf bytes.Buffer if err = jpeg.Encode(&buf, img, &jpeg.Options{Quality: params.quality}); err != nil { return fmt.Errorf("failed to encode full image: %w", err) } err = s.media.Upload(params.ctx, s.bucketName, params.object, &buf, -1) if err != nil { log.WithFields(log.Fields{ "error": err, "img type": params.imageType, }).Error("Merch | Failed to upload file to media storage") return err } return nil } // mtUploadMerchImage // Upload new/rewrite existing image to merch-tracker images storage func (s *service) mtUploadMerchImage(ctx context.Context, userUuid, merchUuid string, file *multipart.FileHeader) (*is.UploadMerchImageResponse, error) { const uploadMerchImage = "Merch service | Upload merch image" exists, err := s.repo.merchRecordExists(userUuid, merchUuid) if err != nil { log.WithError(err).Error(uploadMerchImage) return nil, err } if !exists { err = fmt.Errorf("no merch found for user %s with uuid %s", userUuid, merchUuid) log.WithError(err).Error(uploadMerchImage) return nil, err } f, err := file.Open() if err != nil { log.WithError(err).Error(uploadMerchImage) return nil, err } defer f.Close() data, err := io.ReadAll(f) if err != nil { log.WithError(err).Error(uploadMerchImage) return nil, err } response, err := s.imageStorage.UploadImage(ctx, &is.UploadMerchImageRequest{ ImageData: data, UserUuid: userUuid, MerchUuid: merchUuid, }) if err != nil { log.WithError(err).Error(uploadMerchImage) return nil, err } return response, nil } // mtDeleteMerchImage // Delete all merch images for given user and merch uuid-s from merch-tracker images storage func (s *service) mtDeleteMerchImage(ctx context.Context, userUuid, merchUuid string) error { exists, err := s.repo.merchRecordExists(userUuid, merchUuid) if err != nil { return err } if !exists { return fmt.Errorf("no merch found for user %s with uuid %s", userUuid, merchUuid) } s.imageStorage.DeleteImage(ctx, &is.DeleteImageRequest{ UserUuid: userUuid, MerchUuid: merchUuid, }) return nil } func (s *service) createLabel(label LabelDTO, userUuid string) error { now := time.Now().UTC() if label.Name == "" { return fmt.Errorf("label name is required") } newLabel := Label{ CreatedAt: now, UpdatedAt: now, DeletedAt: sql.NullTime{Time: time.Time{}, Valid: false}, LabelUuid: uuid.NewString(), UserUuid: userUuid, Name: label.Name, Color: label.Color, BgColor: label.BgColor, } return s.repo.createLabel(newLabel) } func (s *service) getLabels(userUuid string) ([]LabelsList, error) { stored, err := s.repo.getLabels(userUuid) if err != nil { return nil, err } response := make([]LabelsList, 0, len(stored)) for _, label := range stored { response = append(response, LabelsList{ LabelUuid: label.LabelUuid, Name: label.Name, Color: label.Color, BgColor: label.BgColor, }) } return response, nil } func (s *service) updateLabel(userUuid, labelUuid string, label LabelDTO) error { updateMap := make(map[string]any, 3) if label.Name != "" { updateMap["name"] = label.Name } if label.Color != "" { updateMap["color"] = label.Color } if label.BgColor != "" { updateMap["bgcolor"] = label.BgColor } return s.repo.updateLabel(userUuid, labelUuid, updateMap) } func (s *service) deleteLabel(userUuid, labelUuid string) error { return s.repo.deleteLabel(userUuid, labelUuid) } func (s *service) attachLabel(userUuid string, label LabelLink) error { if label.LabelUuid == "" || label.MerchUuid == "" { return fmt.Errorf("both label and merch uuid-s are required") } attach := CardLabel{ LabelUuid: label.LabelUuid, UserUuid: userUuid, MerchUuid: label.MerchUuid, } return s.repo.attachLabel(attach) } func (s *service) detachLabel(userUuid string, label LabelLink) error { if label.LabelUuid == "" || label.MerchUuid == "" { return fmt.Errorf("both label and merch uuid-s are required") } detach := CardLabel{ LabelUuid: label.LabelUuid, UserUuid: userUuid, MerchUuid: label.MerchUuid, } return s.repo.detachLabel(detach) }