27389164b0
- Implemented image folder repository with CRUD operations. - Added image reference repository for managing image references to articles. - Created image repository for handling image assets, including listing, inserting, updating, and deleting images. - Introduced image usage repository to track storage usage and quotas for tenants. - Added SQL queries for image assets, folders, references, and usage. - Developed image handler for HTTP endpoints to manage images and folders. - Created database migration scripts for image-related tables and structures.
216 lines
6.3 KiB
Go
216 lines
6.3 KiB
Go
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
|
|
}
|