package asset import ( "context" "errors" "strings" "time" "github.com/google/uuid" "github.com/rs/zerolog" "go.uber.org/fx" "golang.org/x/sync/errgroup" domainAsset "base/internal/domain/asset" "base/internal/dto" ) var ( ErrAssetNotFound = errors.New("asset not found") ErrCategoryNotFound = errors.New("asset category not found") ) type Service interface { Create(ctx context.Context, req dto.CreateAssetRequest) (*dto.AssetResponse, error) GetByID(ctx context.Context, id uuid.UUID) (*dto.AssetResponse, error) Update(ctx context.Context, req dto.UpdateAssetRequest) (*dto.AssetResponse, error) Delete(ctx context.Context, id uuid.UUID) error FindByProfileID(ctx context.Context, profileID uuid.UUID) (*dto.ListAssetsResponse, error) ListCategories(ctx context.Context) (*dto.ListCategoriesResponse, error) ListByCategoryID(ctx context.Context, categoryID uuid.UUID, limit, page int) (*dto.ListAssetsByCategoryIDResponse, error) GetCategoriesWithPreview(ctx context.Context, req dto.CategoriesPreviewRequest) (*dto.CategoriesPreviewResponse, error) } type service struct { logger zerolog.Logger assetRepo domainAsset.AssetRepository categoryRepo domainAsset.CategoryRepository } type Param struct { Logger zerolog.Logger AssetRepo domainAsset.AssetRepository CategoryRepo domainAsset.CategoryRepository fx.In } func New(param Param) Service { return &service{ logger: param.Logger, assetRepo: param.AssetRepo, categoryRepo: param.CategoryRepo, } } func (s *service) Create(ctx context.Context, req dto.CreateAssetRequest) (*dto.AssetResponse, error) { profileID, err := uuid.Parse(req.ProfileID) if err != nil { return nil, ErrAssetNotFound } categoryID, err := uuid.Parse(req.AssetCategoryID) if err != nil { return nil, ErrCategoryNotFound } // Verify category exists category, err := s.categoryRepo.FindByID(ctx, categoryID) if err != nil || category == nil { return nil, ErrCategoryNotFound } asset := &domainAsset.Asset{ ID: uuid.New(), ProfileID: profileID, AssetCategoryID: categoryID, AssetCategory: *category, Title: req.Title, Description: req.Description, Link: req.Link, Status: domainAsset.StatusPublished, } if err = s.assetRepo.Create(ctx, asset); err != nil { return nil, err } return s.toAssetResponse(asset), nil } func (s *service) GetByID(ctx context.Context, id uuid.UUID) (*dto.AssetResponse, error) { asset, err := s.assetRepo.FindByID(ctx, id) if err != nil { return nil, ErrAssetNotFound } return s.toAssetResponse(asset), nil } func (s *service) Update(ctx context.Context, req dto.UpdateAssetRequest) (*dto.AssetResponse, error) { id, err := uuid.Parse(req.ID) if err != nil { return nil, ErrAssetNotFound } asset, err := s.assetRepo.FindByID(ctx, id) if err != nil { return nil, ErrAssetNotFound } asset.Title = req.Title asset.Description = req.Description asset.Link = req.Link if req.AssetCategoryID != "" { categoryID, err := uuid.Parse(req.AssetCategoryID) if err == nil { category, err := s.categoryRepo.FindByID(ctx, categoryID) if err == nil && category != nil { asset.AssetCategoryID = categoryID asset.AssetCategory = *category } } } if req.Status != nil && *req.Status >= 0 && *req.Status <= 3 { asset.Status = domainAsset.Status(*req.Status) } if err := s.assetRepo.Update(ctx, asset); err != nil { return nil, err } return s.toAssetResponse(asset), nil } func (s *service) Delete(ctx context.Context, id uuid.UUID) error { asset, err := s.assetRepo.FindByID(ctx, id) if err != nil { return ErrAssetNotFound } return s.assetRepo.Delete(ctx, asset) } func (s *service) FindByProfileID(ctx context.Context, profileID uuid.UUID) (*dto.ListAssetsResponse, error) { assets, err := s.assetRepo.FindByProfileID(ctx, profileID) if err != nil { return nil, err } resp := &dto.ListAssetsResponse{ Assets: make([]dto.AssetResponse, len(assets)), } for i, a := range assets { resp.Assets[i] = *s.toAssetResponse(a) } return resp, nil } func (s *service) ListCategories(ctx context.Context) (*dto.ListCategoriesResponse, error) { categories, err := s.categoryRepo.FindAll(ctx) if err != nil { return nil, err } resp := &dto.ListCategoriesResponse{ Categories: make([]dto.CategoryDTO, len(categories)), } for i, c := range categories { resp.Categories[i] = dto.CategoryDTO{ ID: c.ID, Name: c.Name, Icon: c.Icon, Color: c.Color, CardType: c.CardType, Featured: c.Featured, Description: c.Description, } } return resp, nil } func (s *service) ListByCategoryID(ctx context.Context, categoryID uuid.UUID, limit, page int) (*dto.ListAssetsByCategoryIDResponse, error) { if limit < 1 { limit = 10 } if page < 1 { page = 1 } category, err := s.categoryRepo.FindByID(ctx, categoryID) if err != nil || category == nil { return nil, ErrCategoryNotFound } total, err := s.assetRepo.CountByCategory(ctx, categoryID) if err != nil { return nil, err } offset := (page - 1) * limit assets, err := s.assetRepo.FindLatestByCategoryPaginated(ctx, categoryID, limit, offset) if err != nil { return nil, err } totalPages := (total + limit - 1) / limit if totalPages < 1 { totalPages = 1 } resp := &dto.ListAssetsByCategoryIDResponse{ Category: dto.CategoryDTO{ ID: category.ID, Name: category.Name, Icon: category.Icon, Color: category.Color, CardType: category.CardType, Featured: category.Featured, Description: category.Description, }, Assets: make([]dto.AssetResponse, len(assets)), Total: total, Page: page, PageSize: limit, TotalPages: totalPages, } for i, a := range assets { resp.Assets[i] = *s.toAssetResponse(a) } return resp, nil } func (s *service) GetCategoriesWithPreview(ctx context.Context, req dto.CategoriesPreviewRequest) (*dto.CategoriesPreviewResponse, error) { perCategory := req.AssetsPerCategory if perCategory < 1 { perCategory = 8 } if perCategory > 20 { perCategory = 20 } var categoryIDs []uuid.UUID for _, s := range req.CategoryIDs { if id, err := uuid.Parse(s); err == nil { categoryIDs = append(categoryIDs, id) } } var categories []*domainAsset.Category var err error if len(categoryIDs) > 0 { categories, err = s.categoryRepo.FindByIDs(ctx, categoryIDs) } else { categories, err = s.categoryRepo.FindAll(ctx) } if err != nil { return nil, err } if req.FeaturedOnly { filtered := make([]*domainAsset.Category, 0, len(categories)) for _, c := range categories { if c.Featured { filtered = append(filtered, c) } } categories = filtered } results := make([]dto.CategoryWithPreviewAssetsDTO, len(categories)) g, gCtx := errgroup.WithContext(ctx) for index, category := range categories { i, cat := index, category g.Go(func() error { assets, assetErr := s.assetRepo.FindLatestByCategory(gCtx, cat.ID, perCategory) if assetErr != nil { return assetErr } total, _ := s.assetRepo.CountByCategory(gCtx, cat.ID) assetResps := make([]dto.AssetResponse, len(assets)) for j, a := range assets { assetResps[j] = *s.toAssetResponse(a) } results[i] = dto.CategoryWithPreviewAssetsDTO{ Category: dto.CategoryDTO{ ID: cat.ID, Name: cat.Name, Icon: cat.Icon, Color: cat.Color, CardType: cat.CardType, Featured: cat.Featured, Description: cat.Description, }, Assets: assetResps, TotalAssets: total, HasMore: total > perCategory, } return nil }) } if err := g.Wait(); err != nil { return nil, err } return &dto.CategoriesPreviewResponse{Categories: results}, nil } func (s *service) toAssetResponse(a *domainAsset.Asset) *dto.AssetResponse { coverImage := "" for _, art := range a.AssetArtifacts { if strings.Contains(strings.ToLower(art.Type), "image") { coverImage = art.DownloadURL break } } resp := &dto.AssetResponse{ ID: a.ID, ProfileID: a.ProfileID, AssetCategoryID: a.AssetCategoryID, Title: a.Title, Description: a.Description, Link: a.Link, CoverImage: coverImage, Status: int(a.Status), CreatedAt: formatTime(a.CreatedAt), UpdatedAt: formatTime(a.UpdatedAt), } resp.Category = dto.CategoryDTO{ ID: a.AssetCategory.ID, Name: a.AssetCategory.Name, Icon: a.AssetCategory.Icon, Color: a.AssetCategory.Color, CardType: a.AssetCategory.CardType, Featured: a.AssetCategory.Featured, Description: a.AssetCategory.Description, } return resp } func formatTime(t time.Time) string { if t.IsZero() { return "" } return t.Format(time.RFC3339) }