package app import ( "bytes" "context" "encoding/json" "strings" "time" "github.com/jackc/pgx/v5/pgxpool" "github.com/geo-platform/tenant-api/internal/shared/auth" "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") errKolPromptDraftEmpty = response.ErrConflict(40961, "kol_prompt_draft_missing", "no draft revision to publish") ) 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 SaveDraftInput struct { PromptID int64 `json:"-"` 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 KolPromptRevisionResponse struct { ID int64 `json:"id"` PromptID int64 `json:"prompt_id"` RevisionNo int `json:"revision_no"` CreatedAt time.Time `json:"created_at"` } type KolPromptService struct { pool *pgxpool.Pool profileSvc *KolProfileService pkgRepo repository.KolPackageRepository promptRepo repository.KolPromptRepository asset *KolPromptAsset } func NewKolPromptService( pool *pgxpool.Pool, profileSvc *KolProfileService, pkgRepo repository.KolPackageRepository, promptRepo repository.KolPromptRepository, asset *KolPromptAsset, ) *KolPromptService { return &KolPromptService{ pool: pool, profileSvc: profileSvc, pkgRepo: pkgRepo, promptRepo: promptRepo, asset: asset, } } 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()) } 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 } return s.buildPromptDetail(ctx, updated, false) } func (s *KolPromptService) SaveDraft(ctx context.Context, actor auth.Actor, input SaveDraftInput) (*KolPromptRevisionResponse, error) { 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()) } _, prompt, _, err := s.loadOwnedPrompt(ctx, actor, input.PromptID) if err != nil { return nil, err } nextRevisionNo, err := s.promptRepo.NextRevisionNo(ctx, actor.TenantID, input.PromptID) if err != nil { return nil, response.ErrInternal(50073, "kol_prompt_revision_query_failed", err.Error()) } assetKey, err := s.asset.Put(ctx, actor.TenantID, input.PromptID, nextRevisionNo, 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(50075, "kol_prompt_draft_tx_failed", "failed to begin prompt draft transaction") } defer tx.Rollback(ctx) promptTx := repository.NewKolPromptRepository(tx) revision, err := promptTx.CreateRevision(ctx, repository.CreateKolPromptRevisionInput{ TenantID: actor.TenantID, PromptID: prompt.ID, RevisionNo: nextRevisionNo, PromptAssetKey: assetKey, SchemaJSON: schemaJSON, CardConfigJSON: cardConfigJSON, CreatedBy: actor.UserID, }) if err != nil { return nil, response.ErrInternal(50076, "kol_prompt_draft_create_failed", err.Error()) } if err := promptTx.SetDraftRevision(ctx, actor.TenantID, prompt.ID, nextRevisionNo, actor.UserID); err != nil { return nil, response.ErrInternal(50077, "kol_prompt_draft_update_failed", err.Error()) } if err := tx.Commit(ctx); err != nil { return nil, response.ErrInternal(50078, "kol_prompt_draft_commit_failed", err.Error()) } return &KolPromptRevisionResponse{ ID: revision.ID, PromptID: revision.PromptID, RevisionNo: revision.RevisionNo, CreatedAt: revision.CreatedAt, }, nil } func (s *KolPromptService) Publish(ctx context.Context, actor auth.Actor, promptID int64) error { _, prompt, _, err := s.loadOwnedPrompt(ctx, actor, promptID) if err != nil { return err } if prompt.DraftRevisionNo == nil { return errKolPromptDraftEmpty } tx, err := s.pool.Begin(ctx) if err != nil { return response.ErrInternal(50079, "kol_prompt_publish_tx_failed", "failed to begin prompt publish transaction") } defer tx.Rollback(ctx) if err := repository.NewKolPromptRepository(tx).SetPublishedRevision(ctx, actor.TenantID, promptID, *prompt.DraftRevisionNo, actor.UserID); err != nil { return response.ErrInternal(50080, "kol_prompt_publish_failed", err.Error()) } if err := tx.Commit(ctx); err != nil { return response.ErrInternal(50081, "kol_prompt_publish_commit_failed", err.Error()) } return nil } 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()) } 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, } revisionNo := prompt.DraftRevisionNo if revisionNo == nil { revisionNo = prompt.PublishedRevisionNo } if revisionNo == nil { return detail, nil } revision, err := s.promptRepo.GetRevision(ctx, prompt.TenantID, prompt.ID, *revisionNo) if err != nil { return nil, response.ErrInternal(50083, "kol_prompt_revision_query_failed", err.Error()) } if revision == nil { return nil, response.ErrInternal(50084, "kol_prompt_revision_not_found", "prompt revision not found") } schema, err := decodeKolSchema(revision.SchemaJSON) if err != nil { return nil, response.ErrInternal(50085, "kol_prompt_schema_decode_failed", err.Error()) } cardConfig, err := decodeKolCardConfig(revision.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 { content, err := s.asset.Get(ctx, revision.PromptAssetKey) if err != nil { return nil, response.ErrInternal(50087, "kol_prompt_asset_load_failed", err.Error()) } detail.LatestPromptContent = &content } return detail, nil } func normalizeKolCardConfig(cardConfig map[string]interface{}) map[string]interface{} { if cardConfig == nil { return map[string]interface{}{} } return cardConfig } 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 map[string]interface{}{}, nil } var cardConfig map[string]interface{} if err := json.Unmarshal(trimmed, &cardConfig); err != nil { return nil, err } return normalizeKolCardConfig(cardConfig), nil }