package app import ( "context" "encoding/json" "strings" "time" "github.com/geo-platform/tenant-api/internal/shared/auth" sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache" "github.com/geo-platform/tenant-api/internal/shared/response" "github.com/geo-platform/tenant-api/internal/tenant/repository" ) type CreateKolPackageInput struct { Name string `json:"name" binding:"required"` Description *string `json:"description"` CoverURL *string `json:"cover_url"` Industry *string `json:"industry"` Tags []string `json:"tags"` PriceNote *string `json:"price_note"` } type UpdateKolPackageInput = CreateKolPackageInput type KolPackageResponse struct { ID int64 `json:"id"` KolProfileID int64 `json:"kol_profile_id"` KolDisplayName string `json:"kol_display_name"` KolAvatarURL *string `json:"kol_avatar_url"` Name string `json:"name"` Description *string `json:"description"` CoverURL *string `json:"cover_url"` Industry *string `json:"industry"` Tags []string `json:"tags"` PriceNote *string `json:"price_note"` Status string `json:"status"` PromptCount int `json:"prompt_count"` SubscriberCount int `json:"subscriber_count"` SelfSubscription *KolSubscriptionResponse `json:"self_subscription"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } type KolPackageService struct { profileSvc *KolProfileService repo repository.KolPackageRepository promptRepo repository.KolPromptRepository subRepo repository.KolSubscriptionRepository cache sharedcache.Cache } func NewKolPackageService( profileSvc *KolProfileService, repo repository.KolPackageRepository, promptRepo repository.KolPromptRepository, subRepo repository.KolSubscriptionRepository, ) *KolPackageService { return &KolPackageService{ profileSvc: profileSvc, repo: repo, promptRepo: promptRepo, subRepo: subRepo, } } func (s *KolPackageService) WithCache(c sharedcache.Cache) *KolPackageService { s.cache = c return s } func (s *KolPackageService) List(ctx context.Context, actor auth.Actor) ([]KolPackageResponse, error) { profile, err := s.profileSvc.RequireActiveKol(ctx, actor) if err != nil { return nil, err } packages, err := s.repo.ListByProfile(ctx, actor.TenantID, profile.ID) if err != nil { return nil, response.ErrInternal(50062, "kol_package_query_failed", err.Error()) } result := make([]KolPackageResponse, 0, len(packages)) for _, pkg := range packages { item, err := s.buildPackageResponse(ctx, profile, &pkg) if err != nil { return nil, err } result = append(result, *item) } return result, nil } func (s *KolPackageService) Get(ctx context.Context, actor auth.Actor, id int64) (*KolPackageResponse, error) { profile, pkg, err := s.loadOwnedPackage(ctx, actor, id) if err != nil { return nil, err } return s.buildPackageResponse(ctx, profile, pkg) } func (s *KolPackageService) Create(ctx context.Context, actor auth.Actor, input CreateKolPackageInput) (*KolPackageResponse, error) { profile, err := s.profileSvc.RequireActiveKol(ctx, actor) if err != nil { return nil, err } name := strings.TrimSpace(input.Name) if name == "" { return nil, response.ErrBadRequest(40061, "kol_package_name_required", "package name is required") } tagsJSON, err := json.Marshal(normalizeKolStringList(input.Tags)) if err != nil { return nil, response.ErrBadRequest(40062, "kol_package_tags_invalid", "tags must be valid json") } pkg, err := s.repo.Create(ctx, repository.CreateKolPackageInput{ TenantID: actor.TenantID, KolProfileID: profile.ID, Name: name, Description: normalizeKolOptionalString(input.Description), CoverURL: normalizeKolOptionalString(input.CoverURL), Industry: normalizeKolOptionalString(input.Industry), Tags: tagsJSON, PriceNote: normalizeKolOptionalString(input.PriceNote), }) if err != nil { return nil, response.ErrInternal(50063, "kol_package_create_failed", err.Error()) } InvalidateKolPromptCaches(ctx, s.cache) return s.buildPackageResponse(ctx, profile, pkg) } func (s *KolPackageService) Update(ctx context.Context, actor auth.Actor, id int64, input UpdateKolPackageInput) (*KolPackageResponse, error) { profile, _, err := s.loadOwnedPackage(ctx, actor, id) if err != nil { return nil, err } name := strings.TrimSpace(input.Name) if name == "" { return nil, response.ErrBadRequest(40061, "kol_package_name_required", "package name is required") } tagsJSON, err := json.Marshal(normalizeKolStringList(input.Tags)) if err != nil { return nil, response.ErrBadRequest(40062, "kol_package_tags_invalid", "tags must be valid json") } if err := s.repo.Update(ctx, actor.TenantID, repository.UpdateKolPackageInput{ ID: id, Name: name, Description: normalizeKolOptionalString(input.Description), CoverURL: normalizeKolOptionalString(input.CoverURL), Industry: normalizeKolOptionalString(input.Industry), Tags: tagsJSON, PriceNote: normalizeKolOptionalString(input.PriceNote), }); err != nil { return nil, response.ErrInternal(50064, "kol_package_update_failed", err.Error()) } updated, err := s.repo.GetByID(ctx, actor.TenantID, id) if err != nil { return nil, response.ErrInternal(50062, "kol_package_query_failed", err.Error()) } if updated == nil { return nil, errKolPackageNotFound } InvalidateKolPromptCaches(ctx, s.cache) return s.buildPackageResponse(ctx, profile, updated) } func (s *KolPackageService) Publish(ctx context.Context, actor auth.Actor, id int64) (*KolPackageResponse, error) { profile, _, err := s.loadOwnedPackage(ctx, actor, id) if err != nil { return nil, err } if err := s.repo.UpdateStatus(ctx, actor.TenantID, id, "published"); err != nil { return nil, response.ErrInternal(50065, "kol_package_publish_failed", err.Error()) } updated, err := s.repo.GetByID(ctx, actor.TenantID, id) if err != nil { return nil, response.ErrInternal(50062, "kol_package_query_failed", err.Error()) } if updated == nil { return nil, errKolPackageNotFound } InvalidateKolPromptCaches(ctx, s.cache) return s.buildPackageResponse(ctx, profile, updated) } func (s *KolPackageService) Archive(ctx context.Context, actor auth.Actor, id int64) (*KolPackageResponse, error) { profile, _, err := s.loadOwnedPackage(ctx, actor, id) if err != nil { return nil, err } if err := s.repo.UpdateStatus(ctx, actor.TenantID, id, "archived"); err != nil { return nil, response.ErrInternal(50066, "kol_package_archive_failed", err.Error()) } updated, err := s.repo.GetByID(ctx, actor.TenantID, id) if err != nil { return nil, response.ErrInternal(50062, "kol_package_query_failed", err.Error()) } if updated == nil { return nil, errKolPackageNotFound } InvalidateKolPromptCaches(ctx, s.cache) return s.buildPackageResponse(ctx, profile, updated) } func (s *KolPackageService) Delete(ctx context.Context, actor auth.Actor, id int64) error { if _, _, err := s.loadOwnedPackage(ctx, actor, id); err != nil { return err } if err := s.repo.SoftDelete(ctx, actor.TenantID, id); err != nil { return response.ErrInternal(50067, "kol_package_delete_failed", err.Error()) } InvalidateKolPromptCaches(ctx, s.cache) return nil } func (s *KolPackageService) SelfSubscribe(ctx context.Context, actor auth.Actor, id int64) (*KolPackageResponse, error) { profile, pkg, err := s.loadOwnedPackage(ctx, actor, id) if err != nil { return nil, err } if pkg.Status != "published" { return nil, response.ErrConflict(40973, "kol_package_not_published", "package must be published before self subscription") } if s.subRepo == nil { return nil, response.ErrInternal(50093, "kol_subscription_repo_missing", "subscription repository is not configured") } if _, err := s.subRepo.SelfSubscribe(ctx, actor.TenantID, id); err != nil { if isUniqueConstraintError(err, "uk_kol_subscription_active") { return nil, errKolSubscriptionExists } return nil, response.ErrInternal(50094, "kol_self_subscription_failed", err.Error()) } InvalidateKolPromptCaches(ctx, s.cache) return s.buildPackageResponse(ctx, profile, pkg) } func (s *KolPackageService) CancelSelfSubscription(ctx context.Context, actor auth.Actor, id int64) (*KolPackageResponse, error) { profile, pkg, err := s.loadOwnedPackage(ctx, actor, id) if err != nil { return nil, err } if s.subRepo == nil { return nil, response.ErrInternal(50093, "kol_subscription_repo_missing", "subscription repository is not configured") } sub, err := s.subRepo.CancelSelfSubscription(ctx, actor.TenantID, id) if err != nil { return nil, response.ErrInternal(50095, "kol_self_subscription_cancel_failed", err.Error()) } if sub == nil { return nil, errKolSubscriptionNotFound } InvalidateKolPromptCaches(ctx, s.cache) return s.buildPackageResponse(ctx, profile, pkg) } func (s *KolPackageService) loadOwnedPackage(ctx context.Context, actor auth.Actor, id int64) (*repository.KolProfile, *repository.KolPackage, error) { profile, err := s.profileSvc.RequireActiveKol(ctx, actor) if err != nil { return nil, nil, err } pkg, err := s.repo.GetByID(ctx, actor.TenantID, id) if err != nil { return nil, nil, response.ErrInternal(50062, "kol_package_query_failed", err.Error()) } if pkg == nil { return nil, nil, errKolPackageNotFound } if pkg.KolProfileID != profile.ID { return nil, nil, ErrPackageNotOwned } return profile, pkg, nil } func (s *KolPackageService) buildPackageResponse( ctx context.Context, profile *repository.KolProfile, pkg *repository.KolPackage, ) (*KolPackageResponse, error) { tags, err := decodeKolStringList(pkg.Tags) if err != nil { return nil, response.ErrInternal(50068, "kol_package_decode_failed", err.Error()) } promptCount := 0 if s.promptRepo != nil { prompts, err := s.promptRepo.ListByPackage(ctx, pkg.TenantID, pkg.ID) if err != nil { return nil, response.ErrInternal(50069, "kol_prompt_query_failed", err.Error()) } promptCount = len(prompts) } var selfSubscription *KolSubscriptionResponse if s.subRepo != nil { sub, err := s.subRepo.GetActiveForTenant(ctx, pkg.TenantID, pkg.ID) if err != nil { return nil, response.ErrInternal(50093, "kol_subscription_query_failed", err.Error()) } selfSubscription = newKolSubscriptionResponse(sub) } return &KolPackageResponse{ ID: pkg.ID, KolProfileID: pkg.KolProfileID, KolDisplayName: profile.DisplayName, KolAvatarURL: profile.AvatarURL, Name: pkg.Name, Description: pkg.Description, CoverURL: pkg.CoverURL, Industry: pkg.Industry, Tags: tags, PriceNote: pkg.PriceNote, Status: pkg.Status, PromptCount: promptCount, SubscriberCount: 0, SelfSubscription: selfSubscription, CreatedAt: pkg.CreatedAt, UpdatedAt: pkg.UpdatedAt, }, nil } func normalizeKolOptionalString(value *string) *string { if value == nil { return nil } trimmed := strings.TrimSpace(*value) if trimmed == "" { return nil } return &trimmed } func normalizeKolStringList(values []string) []string { if len(values) == 0 { return []string{} } result := make([]string, 0, len(values)) seen := make(map[string]struct{}, len(values)) for _, value := range values { trimmed := strings.TrimSpace(value) if trimmed == "" { continue } if _, exists := seen[trimmed]; exists { continue } seen[trimmed] = struct{}{} result = append(result, trimmed) } return result } func decodeKolStringList(raw []byte) ([]string, error) { if len(raw) == 0 { return []string{}, nil } var values []string if err := json.Unmarshal(raw, &values); err != nil { return nil, err } return normalizeKolStringList(values), nil }