package app import ( "bytes" "context" "encoding/json" "strings" "time" "github.com/jackc/pgx/v5/pgxpool" "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" ) var ( errKolPackageNotFound = response.ErrNotFound(40461, "kol_package_not_found", "package not found") ErrPackageNotOwned = response.ErrForbidden(40361, "kol_package_not_owned", "package is not owned by current KOL") ErrPromptNotFound = response.ErrNotFound(40462, "kol_prompt_not_found", "prompt not found") ErrPromptNotOwned = response.ErrForbidden(40362, "kol_prompt_not_owned", "prompt is not owned by current KOL") errKolPromptContentEmpty = response.ErrConflict(40961, "kol_prompt_content_missing", "prompt content is required before publish or activate") ) type CreatePromptInput struct { PackageID int64 `json:"-"` Name string `json:"name" binding:"required"` PlatformHint *string `json:"platform_hint"` } type UpdateKolPromptInput struct { Name string `json:"name" binding:"required"` PlatformHint *string `json:"platform_hint"` SortOrder *int `json:"sort_order"` } type PublishPromptInput struct { Name string `json:"name" binding:"required"` PlatformHint *string `json:"platform_hint"` SortOrder *int `json:"sort_order"` Content string `json:"content" binding:"required"` Schema KolSchemaJSON `json:"schema" binding:"required"` CardConfig map[string]interface{} `json:"card_config"` } type KolPromptDetailResponse struct { ID int64 `json:"id"` PackageID int64 `json:"package_id"` Name string `json:"name"` PlatformHint *string `json:"platform_hint"` Status string `json:"status"` SortOrder int `json:"sort_order"` PublishedRevisionNo *int `json:"published_revision_no"` DraftRevisionNo *int `json:"draft_revision_no"` LatestSchemaJSON *KolSchemaJSON `json:"latest_schema_json"` LatestCardConfigJSON map[string]interface{} `json:"latest_card_config_json"` LatestPromptContent *string `json:"latest_prompt_content"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } type KolPromptService struct { pool *pgxpool.Pool profileSvc *KolProfileService pkgRepo repository.KolPackageRepository promptRepo repository.KolPromptRepository subRepo repository.KolSubscriptionRepository asset *KolPromptAsset cache sharedcache.Cache } func NewKolPromptService( pool *pgxpool.Pool, profileSvc *KolProfileService, pkgRepo repository.KolPackageRepository, promptRepo repository.KolPromptRepository, subRepo repository.KolSubscriptionRepository, asset *KolPromptAsset, ) *KolPromptService { return &KolPromptService{ pool: pool, profileSvc: profileSvc, pkgRepo: pkgRepo, promptRepo: promptRepo, subRepo: subRepo, asset: asset, } } func (s *KolPromptService) WithCache(c sharedcache.Cache) *KolPromptService { s.cache = c return s } func (s *KolPromptService) CreatePrompt(ctx context.Context, actor auth.Actor, input CreatePromptInput) (*KolPromptDetailResponse, error) { if strings.TrimSpace(input.Name) == "" { return nil, response.ErrBadRequest(40063, "kol_prompt_name_required", "prompt name is required") } if _, _, err := s.loadOwnedPackage(ctx, actor, input.PackageID); err != nil { return nil, err } prompt, err := s.promptRepo.Create(ctx, repository.CreateKolPromptInput{ TenantID: actor.TenantID, PackageID: input.PackageID, Name: strings.TrimSpace(input.Name), PlatformHint: normalizeKolOptionalString(input.PlatformHint), SortOrder: 0, CreatedBy: actor.UserID, }) if err != nil { return nil, response.ErrInternal(50070, "kol_prompt_create_failed", err.Error()) } InvalidateKolPromptCaches(ctx, s.cache) return s.buildPromptDetail(ctx, prompt, false) } func (s *KolPromptService) ListByPackage(ctx context.Context, actor auth.Actor, packageID int64) ([]KolPromptDetailResponse, error) { if _, _, err := s.loadOwnedPackage(ctx, actor, packageID); err != nil { return nil, err } prompts, err := s.promptRepo.ListByPackage(ctx, actor.TenantID, packageID) if err != nil { return nil, response.ErrInternal(50071, "kol_prompt_query_failed", err.Error()) } result := make([]KolPromptDetailResponse, 0, len(prompts)) for _, prompt := range prompts { item, err := s.buildPromptDetail(ctx, &prompt, false) if err != nil { return nil, err } result = append(result, *item) } return result, nil } func (s *KolPromptService) GetForOwner(ctx context.Context, actor auth.Actor, promptID int64) (*KolPromptDetailResponse, error) { _, prompt, _, err := s.loadOwnedPrompt(ctx, actor, promptID) if err != nil { return nil, err } return s.buildPromptDetail(ctx, prompt, true) } func (s *KolPromptService) UpdateMetadata(ctx context.Context, actor auth.Actor, promptID int64, input UpdateKolPromptInput) (*KolPromptDetailResponse, error) { _, prompt, _, err := s.loadOwnedPrompt(ctx, actor, promptID) if err != nil { return nil, err } name := strings.TrimSpace(input.Name) if name == "" { return nil, response.ErrBadRequest(40063, "kol_prompt_name_required", "prompt name is required") } sortOrder := prompt.SortOrder if input.SortOrder != nil { sortOrder = *input.SortOrder } if err := s.promptRepo.UpdateMetadata(ctx, repository.UpdateKolPromptMetadataInput{ ID: promptID, TenantID: actor.TenantID, Name: name, PlatformHint: normalizeKolOptionalString(input.PlatformHint), SortOrder: sortOrder, UpdatedBy: actor.UserID, }); err != nil { return nil, response.ErrInternal(50072, "kol_prompt_update_failed", err.Error()) } updated, err := s.promptRepo.GetByID(ctx, actor.TenantID, promptID) if err != nil { return nil, response.ErrInternal(50071, "kol_prompt_query_failed", err.Error()) } if updated == nil { return nil, ErrPromptNotFound } InvalidateKolPromptCaches(ctx, s.cache) return s.buildPromptDetail(ctx, updated, false) } func (s *KolPromptService) Save(ctx context.Context, actor auth.Actor, promptID int64, input PublishPromptInput) (*KolPromptDetailResponse, error) { _, prompt, _, err := s.loadOwnedPrompt(ctx, actor, promptID) if err != nil { return nil, err } name := strings.TrimSpace(input.Name) if name == "" { return nil, response.ErrBadRequest(40063, "kol_prompt_name_required", "prompt name is required") } if strings.TrimSpace(input.Content) == "" { return nil, response.ErrBadRequest(40064, "kol_prompt_content_required", "prompt content is required") } if err := ValidateSchema(input.Schema); err != nil { return nil, response.ErrBadRequest(40065, "kol_prompt_schema_invalid", err.Error()) } sortOrder := prompt.SortOrder if input.SortOrder != nil { sortOrder = *input.SortOrder } const currentRevisionNo = 1 assetKey, err := s.asset.Put(ctx, actor.TenantID, promptID, currentRevisionNo, input.Content) if err != nil { return nil, response.ErrInternal(50074, "kol_prompt_asset_store_failed", err.Error()) } schemaJSON, err := JSONEncodeSchema(input.Schema) if err != nil { return nil, response.ErrBadRequest(40065, "kol_prompt_schema_invalid", err.Error()) } cardConfigJSON, err := json.Marshal(normalizeKolCardConfig(input.CardConfig)) if err != nil { return nil, response.ErrBadRequest(40066, "kol_prompt_card_config_invalid", "card_config must be valid json") } publishedRevisionNo := prompt.PublishedRevisionNo if prompt.Status == "active" { revNo := currentRevisionNo publishedRevisionNo = &revNo } if err := s.promptRepo.Save(ctx, repository.SaveKolPromptInput{ ID: promptID, TenantID: actor.TenantID, Name: name, PlatformHint: normalizeKolOptionalString(input.PlatformHint), SortOrder: sortOrder, PromptAssetKey: assetKey, SchemaJSON: schemaJSON, CardConfigJSON: cardConfigJSON, Status: prompt.Status, PublishedRevisionNo: publishedRevisionNo, UpdatedBy: actor.UserID, }); err != nil { return nil, response.ErrInternal(50072, "kol_prompt_update_failed", err.Error()) } updated, err := s.promptRepo.GetByID(ctx, actor.TenantID, promptID) if err != nil { return nil, response.ErrInternal(50071, "kol_prompt_query_failed", err.Error()) } if updated == nil { return nil, ErrPromptNotFound } InvalidateKolPromptCaches(ctx, s.cache) return s.buildPromptDetail(ctx, updated, true) } func (s *KolPromptService) Publish(ctx context.Context, actor auth.Actor, promptID int64, input PublishPromptInput) (*KolPromptDetailResponse, error) { _, prompt, pkg, err := s.loadOwnedPrompt(ctx, actor, promptID) if err != nil { return nil, err } name := strings.TrimSpace(input.Name) if name == "" { return nil, response.ErrBadRequest(40063, "kol_prompt_name_required", "prompt name is required") } if strings.TrimSpace(input.Content) == "" { return nil, response.ErrBadRequest(40064, "kol_prompt_content_required", "prompt content is required") } if err := ValidateSchema(input.Schema); err != nil { return nil, response.ErrBadRequest(40065, "kol_prompt_schema_invalid", err.Error()) } sortOrder := prompt.SortOrder if input.SortOrder != nil { sortOrder = *input.SortOrder } const publishedRevisionNo = 1 assetKey, err := s.asset.Put(ctx, actor.TenantID, promptID, publishedRevisionNo, input.Content) if err != nil { return nil, response.ErrInternal(50074, "kol_prompt_asset_store_failed", err.Error()) } schemaJSON, err := JSONEncodeSchema(input.Schema) if err != nil { return nil, response.ErrBadRequest(40065, "kol_prompt_schema_invalid", err.Error()) } cardConfigJSON, err := json.Marshal(normalizeKolCardConfig(input.CardConfig)) if err != nil { return nil, response.ErrBadRequest(40066, "kol_prompt_card_config_invalid", "card_config must be valid json") } tx, err := s.pool.Begin(ctx) if err != nil { return nil, response.ErrInternal(50079, "kol_prompt_publish_tx_failed", "failed to begin prompt publish transaction") } defer tx.Rollback(ctx) promptTx := repository.NewKolPromptRepository(tx) subTx := repository.NewKolSubscriptionRepository(tx) if err := promptTx.Publish(ctx, repository.PublishKolPromptInput{ ID: promptID, TenantID: actor.TenantID, Name: name, PlatformHint: normalizeKolOptionalString(input.PlatformHint), SortOrder: sortOrder, PromptAssetKey: assetKey, SchemaJSON: schemaJSON, CardConfigJSON: cardConfigJSON, PublishedRevisionNo: publishedRevisionNo, UpdatedBy: actor.UserID, }); err != nil { return nil, response.ErrInternal(50080, "kol_prompt_publish_failed", err.Error()) } if err := s.grantPromptToActiveSubscribers(ctx, subTx, pkg.ID, promptID); err != nil { return nil, err } if err := tx.Commit(ctx); err != nil { return nil, response.ErrInternal(50081, "kol_prompt_publish_commit_failed", err.Error()) } updated, err := s.promptRepo.GetByID(ctx, actor.TenantID, promptID) if err != nil { return nil, response.ErrInternal(50071, "kol_prompt_query_failed", err.Error()) } if updated == nil { return nil, ErrPromptNotFound } InvalidateKolPromptCaches(ctx, s.cache) return s.buildPromptDetail(ctx, updated, true) } func (s *KolPromptService) Activate(ctx context.Context, actor auth.Actor, promptID int64) (*KolPromptDetailResponse, error) { _, prompt, pkg, err := s.loadOwnedPrompt(ctx, actor, promptID) if err != nil { return nil, err } tx, err := s.pool.Begin(ctx) if err != nil { return nil, response.ErrInternal(50088, "kol_prompt_activate_tx_failed", "failed to begin prompt activate transaction") } defer tx.Rollback(ctx) promptTx := repository.NewKolPromptRepository(tx) subTx := repository.NewKolSubscriptionRepository(tx) switch { case prompt.PromptAssetKey != nil && strings.TrimSpace(*prompt.PromptAssetKey) != "": if err := promptTx.SetPublishedRevision(ctx, actor.TenantID, promptID, 1, actor.UserID); err != nil { return nil, response.ErrInternal(50090, "kol_prompt_activate_failed", err.Error()) } default: return nil, errKolPromptContentEmpty } if err := s.grantPromptToActiveSubscribers(ctx, subTx, pkg.ID, promptID); err != nil { return nil, err } if err := tx.Commit(ctx); err != nil { return nil, response.ErrInternal(50091, "kol_prompt_activate_commit_failed", "failed to commit prompt activate transaction") } updated, err := s.promptRepo.GetByID(ctx, actor.TenantID, promptID) if err != nil { return nil, response.ErrInternal(50071, "kol_prompt_query_failed", err.Error()) } if updated == nil { return nil, ErrPromptNotFound } InvalidateKolPromptCaches(ctx, s.cache) return s.buildPromptDetail(ctx, updated, false) } func (s *KolPromptService) Archive(ctx context.Context, actor auth.Actor, promptID int64) (*KolPromptDetailResponse, error) { if _, _, _, err := s.loadOwnedPrompt(ctx, actor, promptID); err != nil { return nil, err } if err := s.promptRepo.UpdateStatus(ctx, actor.TenantID, promptID, "archived", actor.UserID); err != nil { return nil, response.ErrInternal(50092, "kol_prompt_archive_failed", err.Error()) } updated, err := s.promptRepo.GetByID(ctx, actor.TenantID, promptID) if err != nil { return nil, response.ErrInternal(50071, "kol_prompt_query_failed", err.Error()) } if updated == nil { return nil, ErrPromptNotFound } InvalidateKolPromptCaches(ctx, s.cache) return s.buildPromptDetail(ctx, updated, false) } func (s *KolPromptService) Delete(ctx context.Context, actor auth.Actor, promptID int64) error { if _, _, _, err := s.loadOwnedPrompt(ctx, actor, promptID); err != nil { return err } if err := s.promptRepo.SoftDelete(ctx, actor.TenantID, promptID); err != nil { return response.ErrInternal(50082, "kol_prompt_delete_failed", err.Error()) } InvalidateKolPromptCaches(ctx, s.cache) return nil } func (s *KolPromptService) loadOwnedPackage(ctx context.Context, actor auth.Actor, packageID int64) (*repository.KolProfile, *repository.KolPackage, error) { profile, err := s.profileSvc.RequireActiveKol(ctx, actor) if err != nil { return nil, nil, err } pkg, err := s.pkgRepo.GetByID(ctx, actor.TenantID, packageID) 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 *KolPromptService) loadOwnedPrompt(ctx context.Context, actor auth.Actor, promptID int64) (*repository.KolProfile, *repository.KolPrompt, *repository.KolPackage, error) { profile, err := s.profileSvc.RequireActiveKol(ctx, actor) if err != nil { return nil, nil, nil, err } prompt, err := s.promptRepo.GetByID(ctx, actor.TenantID, promptID) if err != nil { return nil, nil, nil, response.ErrInternal(50071, "kol_prompt_query_failed", err.Error()) } if prompt == nil { return nil, nil, nil, ErrPromptNotFound } pkg, err := s.pkgRepo.GetByID(ctx, actor.TenantID, prompt.PackageID) if err != nil { return nil, nil, nil, response.ErrInternal(50062, "kol_package_query_failed", err.Error()) } if pkg == nil { return nil, nil, nil, errKolPackageNotFound } if pkg.KolProfileID != profile.ID { return nil, nil, nil, ErrPromptNotOwned } return profile, prompt, pkg, nil } func (s *KolPromptService) buildPromptDetail(ctx context.Context, prompt *repository.KolPrompt, includeContent bool) (*KolPromptDetailResponse, error) { detail := &KolPromptDetailResponse{ ID: prompt.ID, PackageID: prompt.PackageID, Name: prompt.Name, PlatformHint: prompt.PlatformHint, Status: prompt.Status, SortOrder: prompt.SortOrder, PublishedRevisionNo: prompt.PublishedRevisionNo, DraftRevisionNo: prompt.DraftRevisionNo, CreatedAt: prompt.CreatedAt, UpdatedAt: prompt.UpdatedAt, } if len(prompt.SchemaJSON) == 0 && len(prompt.CardConfigJSON) == 0 && (prompt.PromptAssetKey == nil || strings.TrimSpace(*prompt.PromptAssetKey) == "") { return detail, nil } schema, err := decodeKolSchema(prompt.SchemaJSON) if err != nil { return nil, response.ErrInternal(50085, "kol_prompt_schema_decode_failed", err.Error()) } cardConfig, err := decodeKolCardConfig(prompt.CardConfigJSON) if err != nil { return nil, response.ErrInternal(50086, "kol_prompt_card_config_decode_failed", err.Error()) } detail.LatestSchemaJSON = schema detail.LatestCardConfigJSON = cardConfig if includeContent && prompt.PromptAssetKey != nil && strings.TrimSpace(*prompt.PromptAssetKey) != "" { content, err := s.asset.Get(ctx, *prompt.PromptAssetKey) if err != nil { return nil, response.ErrInternal(50087, "kol_prompt_asset_load_failed", err.Error()) } detail.LatestPromptContent = &content } return detail, nil } func (s *KolPromptService) grantPromptToActiveSubscribers( ctx context.Context, subRepo repository.KolSubscriptionRepository, packageID, promptID int64, ) error { subscribers, err := subRepo.ListActiveSubscribersForPackage(ctx, packageID) if err != nil { return response.ErrInternal(50083, "kol_subscription_query_failed", err.Error()) } for _, sub := range subscribers { if err := subRepo.CreateAccessRow(ctx, repository.CreateAccessRowInput{ TenantID: sub.TenantID, SubscriptionID: sub.ID, PackageID: packageID, PromptID: promptID, }); err != nil { return response.ErrInternal(50084, "kol_subscription_access_create_failed", err.Error()) } } return nil } func normalizeKolCardConfig(cardConfig map[string]interface{}) map[string]interface{} { normalized := map[string]interface{}{ "allow_web_search": false, "allow_user_knowledge": true, } for key, value := range cardConfig { normalized[key] = value } normalized["allow_web_search"] = kolCardConfigBoolValue(cardConfig["allow_web_search"], false) normalized["allow_user_knowledge"] = kolCardConfigBoolValue(cardConfig["allow_user_knowledge"], true) return normalized } func kolCardConfigBoolValue(value interface{}, defaultValue bool) bool { switch typed := value.(type) { case bool: return typed case *bool: if typed != nil { return *typed } } return defaultValue } func kolCardConfigAllowsWebSearch(cardConfig map[string]interface{}) bool { return kolCardConfigBoolValue(cardConfig["allow_web_search"], false) } func kolCardConfigAllowsUserKnowledge(cardConfig map[string]interface{}) bool { return kolCardConfigBoolValue(cardConfig["allow_user_knowledge"], true) } func decodeKolSchema(raw []byte) (*KolSchemaJSON, error) { schema := normalizeKolSchema(KolSchemaJSON{}) trimmed := bytes.TrimSpace(raw) if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) { return &schema, nil } if err := json.Unmarshal(trimmed, &schema); err != nil { return nil, err } schema = normalizeKolSchema(schema) return &schema, nil } func decodeKolCardConfig(raw []byte) (map[string]interface{}, error) { trimmed := bytes.TrimSpace(raw) if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) { return normalizeKolCardConfig(nil), nil } var cardConfig map[string]interface{} if err := json.Unmarshal(trimmed, &cardConfig); err != nil { return nil, err } return normalizeKolCardConfig(cardConfig), nil }