package app import ( "context" "errors" "strings" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgxpool" "go.uber.org/zap" "github.com/geo-platform/tenant-api/internal/shared/messaging/rabbitmq" "github.com/geo-platform/tenant-api/internal/shared/middleware" "github.com/geo-platform/tenant-api/internal/shared/objectstorage" "github.com/geo-platform/tenant-api/internal/shared/response" "github.com/geo-platform/tenant-api/internal/tenant/repository" ) type ImageFolderService struct { pool *pgxpool.Pool imageSvc *ImageService } func NewImageFolderService(pool *pgxpool.Pool, objectStorage objectstorage.Client, rabbitMQClient *rabbitmq.Client, logger *zap.Logger) *ImageFolderService { return &ImageFolderService{ pool: pool, imageSvc: NewImageService(pool, objectStorage, rabbitMQClient, logger), } } type ImageFolderItem struct { ID int64 `json:"id"` Name string `json:"name"` ImageCount int `json:"image_count"` CreatedAt string `json:"created_at"` } type FolderDeletePreview struct { ImageCount int `json:"image_count"` ReferencedList []ImageReferenceArticleBrief `json:"referenced_articles"` } type ImageReferenceArticleBrief struct { ArticleID int64 `json:"article_id"` Scopes []string `json:"scopes"` } func (s *ImageFolderService) List(ctx context.Context) ([]ImageFolderItem, error) { tenantID := middleware.TenantIDFromCtx(ctx) repo := repository.NewImageFolderRepository(s.pool) folders, err := repo.List(ctx, tenantID) if err != nil { return nil, err } items := make([]ImageFolderItem, len(folders)) for i, folder := range folders { items[i] = ImageFolderItem{ ID: folder.ID, Name: folder.Name, ImageCount: folder.ImageCount, CreatedAt: folder.CreatedAt.Format(timeLayoutRFC3339), } } if items == nil { items = []ImageFolderItem{} } return items, nil } func (s *ImageFolderService) Create(ctx context.Context, name string) (int64, error) { name = strings.TrimSpace(name) if err := validateImageFolderName(name); err != nil { return 0, err } tenantID := middleware.TenantIDFromCtx(ctx) repo := repository.NewImageFolderRepository(s.pool) id, err := repo.Create(ctx, tenantID, name) if err != nil { if isConstraintError(err, "uq_image_folders_tenant_name") { return 0, response.ErrConflict(40901, "folder_name_exists", "a folder with this name already exists") } return 0, err } return id, nil } func (s *ImageFolderService) Update(ctx context.Context, id int64, name string) error { name = strings.TrimSpace(name) if err := validateImageFolderName(name); err != nil { return err } tenantID := middleware.TenantIDFromCtx(ctx) repo := repository.NewImageFolderRepository(s.pool) if _, err := repo.Get(ctx, tenantID, id); err != nil { return mapImageFolderNotFound(err) } if err := repo.Update(ctx, tenantID, id, name); err != nil { if isConstraintError(err, "uq_image_folders_tenant_name") { return response.ErrConflict(40901, "folder_name_exists", "a folder with this name already exists") } return err } return nil } func (s *ImageFolderService) DeletePreview(ctx context.Context, folderID int64) (*FolderDeletePreview, error) { tenantID := middleware.TenantIDFromCtx(ctx) folderRepo := repository.NewImageFolderRepository(s.pool) if _, err := folderRepo.Get(ctx, tenantID, folderID); err != nil { return nil, mapImageFolderNotFound(err) } imageCount, err := folderRepo.CountActiveImages(ctx, tenantID, folderID) if err != nil { return nil, err } return &FolderDeletePreview{ ImageCount: imageCount, ReferencedList: []ImageReferenceArticleBrief{}, }, nil } func (s *ImageFolderService) Delete(ctx context.Context, folderID int64, force bool) error { tenantID := middleware.TenantIDFromCtx(ctx) folderRepo := repository.NewImageFolderRepository(s.pool) imageRepo := repository.NewImageRepository(s.pool) if _, err := folderRepo.Get(ctx, tenantID, folderID); err != nil { return mapImageFolderNotFound(err) } imageCount, err := folderRepo.CountActiveImages(ctx, tenantID, folderID) if err != nil { return err } if imageCount == 0 { return folderRepo.Delete(ctx, tenantID, folderID) } if !force { return response.ErrConflict(40902, "folder_not_empty", "folder contains images that will be deleted with the folder") } if s.imageSvc != nil { if err := s.imageSvc.objectStorage.Validate(); err != nil { if errors.Is(err, objectstorage.ErrNotConfigured) { return response.ErrServiceUnavailable(50303, "object_storage_unavailable", "object storage is not configured") } return response.ErrInternal(50019, "image_storage_validation_failed", "failed to validate object storage") } } images, err := imageRepo.ListActiveByFolder(ctx, tenantID, folderID) if err != nil { return err } tx, err := s.pool.Begin(ctx) if err != nil { return response.ErrInternal(50047, "folder_delete_tx_begin_failed", "failed to delete folder") } defer func() { _ = tx.Rollback(ctx) }() imageRepoTx := repository.NewImageRepository(tx) folderRepoTx := repository.NewImageFolderRepository(tx) if err := imageRepoTx.MarkImagesInFolderPendingDelete(ctx, tenantID, folderID); err != nil { return response.ErrInternal(50048, "folder_image_delete_prepare_failed", "failed to prepare folder images for deletion") } if err := folderRepoTx.Delete(ctx, tenantID, folderID); err != nil { return err } if err := tx.Commit(ctx); err != nil { return response.ErrInternal(50049, "folder_delete_tx_commit_failed", "failed to delete folder") } for _, image := range images { if err := s.imageSvc.finalizeDeleteNowOrEnqueue(ctx, tenantID, image.ID); err != nil { return err } } return nil } func validateImageFolderName(name string) error { if name == "" { return response.ErrBadRequest(40001, "invalid_folder_name", "folder name is required") } if len(name) > 100 { return response.ErrBadRequest(40002, "invalid_folder_name", "folder name must be at most 100 characters") } return nil } func mapImageFolderNotFound(err error) error { if errors.Is(err, pgx.ErrNoRows) { return response.ErrNotFound(40402, "image_folder_not_found", "folder not found") } return err } func isConstraintError(err error, constraint string) bool { var pgErr *pgconn.PgError return errors.As(err, &pgErr) && pgErr.ConstraintName == constraint }