first commit
This commit is contained in:
451
go-api/internal/service/definitions.go
Normal file
451
go-api/internal/service/definitions.go
Normal file
@@ -0,0 +1,451 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user