package service import ( "context" "fmt" "net/url" "strconv" "strings" "github.com/krow/krow-backend/go-api/internal/authctx" "github.com/krow/krow-backend/go-api/internal/definition" "github.com/krow/krow-backend/go-api/internal/domain" "github.com/krow/krow-backend/go-api/internal/repo" ) // allowedDefinitionFilters names the accepted query parameters for definition collections. var allowedDefinitionFilters = map[string]bool{ "visibility": true, "status": true, "definition_id": true, "sort": true, "limit": true, "offset": true, } // DefinitionsService manages authored Agent and Skill definitions. type DefinitionsService struct { repo *repo.DefinitionsRepo } // NewDefinitions builds a definitions service over a repository. func NewDefinitions(db repo.Querier) *DefinitionsService { return &DefinitionsService{repo: repo.NewDefinitionsRepo(db)} } // ParseListParams validates query parameters for listing definitions. func (s *DefinitionsService) ParseListParams(q url.Values) (repo.DefinitionListParams, error) { p := repo.DefinitionListParams{ Limit: 100, Sort: "created_date", Desc: true, } for name := range q { if !allowedDefinitionFilters[name] { return p, domain.Invalid(fmt.Sprintf("unknown filter field %q", name)) } } if raw := q.Get("visibility"); raw != "" { if raw != "personal" && raw != "organization" { return p, domain.Invalid("visibility must be one of: personal, organization") } p.Visibility = raw } if raw := q.Get("status"); raw != "" { p.Status = raw } if raw := q.Get("definition_id"); raw != "" { p.DefinitionID = raw } if raw := q.Get("sort"); raw != "" { field := raw desc := false if strings.HasPrefix(field, "-") { desc = true field = field[1:] } switch field { case "created_date", "updated_date", "name", "definition_id", "status", "version": p.Sort = field p.Desc = desc default: return p, domain.Invalid(fmt.Sprintf("cannot sort by %q", field)) } } if raw := q.Get("limit"); raw != "" { n, err := strconv.Atoi(raw) if err != nil || n < 0 { return p, domain.Invalid("limit must be a non-negative integer") } if n > MaxLimit { n = MaxLimit } p.Limit = n } if raw := q.Get("offset"); raw != "" { n, err := strconv.Atoi(raw) if err != nil || n < 0 { return p, domain.Invalid("offset must be a non-negative integer") } p.Offset = n } return p, nil } /* ── Agents ─────────────────────────────────────────────────────────────── */ // ListAgents returns a page of authored agent definitions. func (s *DefinitionsService) ListAgents(ctx context.Context, ident authctx.Identity, p repo.DefinitionListParams) (*domain.Page, error) { records, total, err := s.repo.ListAgents(ctx, ident, p) if err != nil { return nil, err } if records == nil { records = []domain.Record{} } return &domain.Page{ Records: records, Total: total, Limit: p.Limit, Offset: p.Offset, }, nil } // GetAgent returns one agent definition by id within the caller's tenant and ownership scope. func (s *DefinitionsService) GetAgent(ctx context.Context, ident authctx.Identity, id string) (domain.Record, error) { if !isUUID(id) { return nil, domain.NotFound("AgentDefinition", id) } rec, err := s.repo.GetAgent(ctx, ident, id) if err != nil { return nil, err } if rec == nil { return nil, domain.NotFound("AgentDefinition", id) } return rec, nil } // CreateAgent validates, parses and persists a new authored agent definition. func (s *DefinitionsService) CreateAgent(ctx context.Context, ident authctx.Identity, body domain.Record) (domain.Record, error) { mdRaw, ok := body["markdown"] if !ok || mdRaw == nil { return nil, domain.Validation("Paste or upload a Markdown definition.", nil) } markdown, isStr := mdRaw.(string) if !isStr { return nil, domain.Validation("markdown must be a string", nil) } if err := definition.ValidateAgent(markdown); err != nil { return nil, domain.Validation(err.Error(), nil) } visibility := "personal" if visRaw, ok := body["visibility"]; ok && visRaw != nil { v, isStr := visRaw.(string) if !isStr || (v != "personal" && v != "organization") { return nil, domain.Validation("visibility must be one of: personal, organization", map[string]string{"visibility": "invalid"}) } visibility = v } if visibility == "organization" { role, known := domain.ParseRole(ident.Role) if !known || role == domain.RoleTalent { return nil, domain.Forbidden() } } agent, err := definition.ParseAgent(markdown, definition.Options{}) if err != nil { return nil, domain.Validation("That definition could not be parsed. "+err.Error(), nil) } input := repo.AgentInsertInput{ DefinitionID: agent.ID, OrgID: ident.OrgID, Visibility: visibility, CreatedBy: &ident.UserID, Markdown: markdown, Status: agent.Status, Version: agent.Version, Name: agent.Name, Description: agent.Description, Pages: agent.Pages, } if visibility == "personal" { input.OwnerUserID = &ident.UserID } return s.repo.InsertAgent(ctx, ident, input) } // UpdateAgent validates and applies updates to an authored agent definition. func (s *DefinitionsService) UpdateAgent(ctx context.Context, ident authctx.Identity, id string, patch domain.Record) (domain.Record, error) { if !isUUID(id) { return nil, domain.NotFound("AgentDefinition", id) } existing, err := s.repo.GetAgent(ctx, ident, id) if err != nil { return nil, err } if existing == nil { return nil, domain.NotFound("AgentDefinition", id) } if existing["visibility"] == "organization" { role, known := domain.ParseRole(ident.Role) if !known || role == domain.RoleTalent { return nil, domain.Forbidden() } } if visRaw, ok := patch["visibility"]; ok && visRaw != nil { if v, isStr := visRaw.(string); isStr && v != existing["visibility"] { return nil, domain.Validation("visibility cannot be modified after creation", map[string]string{"visibility": "immutable"}) } } var input repo.AgentUpdateInput if mdRaw, ok := patch["markdown"]; ok && mdRaw != nil { markdown, isStr := mdRaw.(string) if !isStr { return nil, domain.Validation("markdown must be a string", nil) } if err := definition.ValidateAgent(markdown); err != nil { return nil, domain.Validation(err.Error(), nil) } agent, err := definition.ParseAgent(markdown, definition.Options{}) if err != nil { return nil, domain.Validation("That definition could not be parsed. "+err.Error(), nil) } input.Markdown = &markdown input.DefinitionID = &agent.ID input.Name = &agent.Name input.Description = &agent.Description input.Status = &agent.Status input.Version = &agent.Version input.Pages = agent.Pages } else if statusRaw, ok := patch["status"]; ok && statusRaw != nil { status, isStr := statusRaw.(string) if !isStr || (status != "draft" && status != "published" && status != "archived") { return nil, domain.Validation("status must be one of: draft, published, archived", map[string]string{"status": "invalid"}) } input.Status = &status } return s.repo.UpdateAgent(ctx, ident, id, input) } // DeleteAgent removes an agent definition following idempotent delete semantics. func (s *DefinitionsService) DeleteAgent(ctx context.Context, ident authctx.Identity, id string) (domain.Record, error) { if !isUUID(id) { return domain.Record{"id": id}, nil } existing, err := s.repo.GetAgent(ctx, ident, id) if err != nil { return nil, err } if existing == nil { return domain.Record{"id": id}, nil } if existing["visibility"] == "organization" { role, known := domain.ParseRole(ident.Role) if !known || role == domain.RoleTalent { return nil, domain.Forbidden() } } if _, err := s.repo.DeleteAgent(ctx, ident, id); err != nil { return nil, err } return domain.Record{"id": id}, nil } /* ── Skills ─────────────────────────────────────────────────────────────── */ // ListSkills returns a page of authored skill definitions. func (s *DefinitionsService) ListSkills(ctx context.Context, ident authctx.Identity, p repo.DefinitionListParams) (*domain.Page, error) { records, total, err := s.repo.ListSkills(ctx, ident, p) if err != nil { return nil, err } if records == nil { records = []domain.Record{} } return &domain.Page{ Records: records, Total: total, Limit: p.Limit, Offset: p.Offset, }, nil } // GetSkill returns one skill definition by id within the caller's tenant and ownership scope. func (s *DefinitionsService) GetSkill(ctx context.Context, ident authctx.Identity, id string) (domain.Record, error) { if !isUUID(id) { return nil, domain.NotFound("SkillDefinition", id) } rec, err := s.repo.GetSkill(ctx, ident, id) if err != nil { return nil, err } if rec == nil { return nil, domain.NotFound("SkillDefinition", id) } return rec, nil } // CreateSkill validates, parses and persists a new authored skill definition. func (s *DefinitionsService) CreateSkill(ctx context.Context, ident authctx.Identity, body domain.Record) (domain.Record, error) { mdRaw, ok := body["markdown"] if !ok || mdRaw == nil { return nil, domain.Validation("Paste or upload a Markdown definition.", nil) } markdown, isStr := mdRaw.(string) if !isStr { return nil, domain.Validation("markdown must be a string", nil) } if err := definition.ValidateSkill(markdown); err != nil { return nil, domain.Validation(err.Error(), nil) } visibility := "personal" if visRaw, ok := body["visibility"]; ok && visRaw != nil { v, isStr := visRaw.(string) if !isStr || (v != "personal" && v != "organization") { return nil, domain.Validation("visibility must be one of: personal, organization", map[string]string{"visibility": "invalid"}) } visibility = v } if visibility == "organization" { role, known := domain.ParseRole(ident.Role) if !known || role == domain.RoleTalent { return nil, domain.Forbidden() } } skill, err := definition.ParseSkill(markdown, definition.Options{}) if err != nil { return nil, domain.Validation("That definition could not be parsed. "+err.Error(), nil) } input := repo.SkillInsertInput{ DefinitionID: skill.ID, OrgID: ident.OrgID, Visibility: visibility, CreatedBy: &ident.UserID, Markdown: markdown, Status: skill.Status, Name: skill.Name, Description: skill.Description, Pages: skill.Pages, } if visibility == "personal" { input.OwnerUserID = &ident.UserID } return s.repo.InsertSkill(ctx, ident, input) } // UpdateSkill validates and applies updates to an authored skill definition. func (s *DefinitionsService) UpdateSkill(ctx context.Context, ident authctx.Identity, id string, patch domain.Record) (domain.Record, error) { if !isUUID(id) { return nil, domain.NotFound("SkillDefinition", id) } existing, err := s.repo.GetSkill(ctx, ident, id) if err != nil { return nil, err } if existing == nil { return nil, domain.NotFound("SkillDefinition", id) } if existing["visibility"] == "organization" { role, known := domain.ParseRole(ident.Role) if !known || role == domain.RoleTalent { return nil, domain.Forbidden() } } if visRaw, ok := patch["visibility"]; ok && visRaw != nil { if v, isStr := visRaw.(string); isStr && v != existing["visibility"] { return nil, domain.Validation("visibility cannot be modified after creation", map[string]string{"visibility": "immutable"}) } } var input repo.SkillUpdateInput if mdRaw, ok := patch["markdown"]; ok && mdRaw != nil { markdown, isStr := mdRaw.(string) if !isStr { return nil, domain.Validation("markdown must be a string", nil) } if err := definition.ValidateSkill(markdown); err != nil { return nil, domain.Validation(err.Error(), nil) } skill, err := definition.ParseSkill(markdown, definition.Options{}) if err != nil { return nil, domain.Validation("That definition could not be parsed. "+err.Error(), nil) } input.Markdown = &markdown input.DefinitionID = &skill.ID input.Name = &skill.Name input.Description = &skill.Description input.Status = &skill.Status input.Pages = skill.Pages } else if statusRaw, ok := patch["status"]; ok && statusRaw != nil { status, isStr := statusRaw.(string) if !isStr || (status != "active" && status != "inactive") { return nil, domain.Validation("status must be one of: active, inactive", map[string]string{"status": "invalid"}) } input.Status = &status } return s.repo.UpdateSkill(ctx, ident, id, input) } // DeleteSkill removes a skill definition following idempotent delete semantics. func (s *DefinitionsService) DeleteSkill(ctx context.Context, ident authctx.Identity, id string) (domain.Record, error) { if !isUUID(id) { return domain.Record{"id": id}, nil } existing, err := s.repo.GetSkill(ctx, ident, id) if err != nil { return nil, err } if existing == nil { return domain.Record{"id": id}, nil } if existing["visibility"] == "organization" { role, known := domain.ParseRole(ident.Role) if !known || role == domain.RoleTalent { return nil, domain.Forbidden() } } if _, err := s.repo.DeleteSkill(ctx, ident, id); err != nil { return nil, err } return domain.Record{"id": id}, nil }