Files
krow_backend/go-api/internal/repo/definitions.go
2026-08-24 13:06:29 +05:30

554 lines
17 KiB
Go

package repo
import (
"context"
"fmt"
"strings"
"github.com/krow/krow-backend/go-api/internal/authctx"
"github.com/krow/krow-backend/go-api/internal/domain"
)
// DefinitionListParams holds the query parameters for listing authored definitions.
type DefinitionListParams struct {
Visibility string
Status string
DefinitionID string
Sort string
Desc bool
Limit int
Offset int
}
// AgentInsertInput holds the fields to insert into agent_definitions.
type AgentInsertInput struct {
DefinitionID string
OrgID string
Visibility string
OwnerUserID *string
CreatedBy *string
Markdown string
Status string
Version int
Name string
Description string
Pages []string
}
// AgentUpdateInput holds the fields to update in agent_definitions.
type AgentUpdateInput struct {
DefinitionID *string
Name *string
Description *string
Status *string
Version *int
Pages []string
Markdown *string
Visibility *string
OwnerUserID *string
}
// SkillInsertInput holds the fields to insert into skill_definitions.
type SkillInsertInput struct {
DefinitionID string
OrgID string
Visibility string
OwnerUserID *string
CreatedBy *string
Markdown string
Status string
Name string
Description string
Pages []string
}
// SkillUpdateInput holds the fields to update in skill_definitions.
type SkillUpdateInput struct {
DefinitionID *string
Name *string
Description *string
Status *string
Pages []string
Markdown *string
Visibility *string
OwnerUserID *string
}
const agentDefinitionColumns = `id::text AS id,
definition_id,
org_id::text AS org_id,
visibility,
owner_user_id::text AS owner_user_id,
created_by::text AS created_by,
markdown,
status,
version,
name,
description,
pages,
to_char(created_date AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') AS created_date,
to_char(updated_date AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') AS updated_date`
const skillDefinitionColumns = `id::text AS id,
definition_id,
org_id::text AS org_id,
visibility,
owner_user_id::text AS owner_user_id,
created_by::text AS created_by,
markdown,
status,
name,
description,
pages,
to_char(created_date AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') AS created_date,
to_char(updated_date AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') AS updated_date`
// DefinitionsRepo handles persistence for agent_definitions and skill_definitions.
type DefinitionsRepo struct {
db Querier
}
// NewDefinitionsRepo builds a repository over a pool or transaction.
func NewDefinitionsRepo(db Querier) *DefinitionsRepo {
return &DefinitionsRepo{db: db}
}
/* ── Agents ─────────────────────────────────────────────────────────────── */
// ListAgents lists agent definitions matching the criteria with tenant isolation.
func (r *DefinitionsRepo) ListAgents(ctx context.Context, ident authctx.Identity, p DefinitionListParams) ([]domain.Record, int, error) {
b := &builder{}
b.where = append(b.where, fmt.Sprintf("org_id = %s::uuid", b.add(ident.OrgID)))
switch p.Visibility {
case "personal":
b.where = append(b.where, fmt.Sprintf("(visibility = 'personal' AND owner_user_id = %s::uuid)", b.add(ident.UserID)))
case "organization":
b.where = append(b.where, "visibility = 'organization'")
default:
b.where = append(b.where, fmt.Sprintf("(visibility = 'organization' OR (visibility = 'personal' AND owner_user_id = %s::uuid))", b.add(ident.UserID)))
}
if p.Status != "" {
b.where = append(b.where, fmt.Sprintf("status = %s::text", b.add(p.Status)))
}
if p.DefinitionID != "" {
b.where = append(b.where, fmt.Sprintf("definition_id = %s::text", b.add(p.DefinitionID)))
}
countSQL := "SELECT count(*)::int FROM agent_definitions" + b.clause()
var total int
if err := r.db.QueryRow(ctx, countSQL, b.args...).Scan(&total); err != nil {
return nil, 0, translate(err)
}
sortCol := "created_date"
switch p.Sort {
case "created_date", "updated_date", "name", "definition_id", "status", "version":
sortCol = p.Sort
}
dir := "ASC"
if p.Desc {
dir = "DESC"
}
orderClause := fmt.Sprintf(" ORDER BY %s %s, id %s", sortCol, dir, dir)
limit := p.Limit
if limit <= 0 {
limit = 100
}
offset := p.Offset
if offset < 0 {
offset = 0
}
limitClause := fmt.Sprintf(" LIMIT %s OFFSET %s", b.add(limit), b.add(offset))
querySQL := "SELECT " + agentDefinitionColumns + " FROM agent_definitions" + b.clause() + orderClause + limitClause
rows, err := r.db.Query(ctx, querySQL, b.args...)
if err != nil {
return nil, 0, translate(err)
}
defer rows.Close()
records, err := collect(rows)
if err != nil {
return nil, 0, translate(err)
}
return records, total, nil
}
// GetAgent retrieves one agent definition by id within the caller's tenant and ownership scope.
func (r *DefinitionsRepo) GetAgent(ctx context.Context, ident authctx.Identity, id string) (domain.Record, error) {
q := fmt.Sprintf(
`SELECT %s FROM agent_definitions
WHERE id = $1::uuid
AND org_id = $2::uuid
AND (visibility = 'organization' OR (visibility = 'personal' AND owner_user_id = $3::uuid))
LIMIT 1`,
agentDefinitionColumns)
rows, err := r.db.Query(ctx, q, id, ident.OrgID, ident.UserID)
if err != nil {
return nil, translate(err)
}
defer rows.Close()
records, err := collect(rows)
if err != nil {
return nil, translate(err)
}
if len(records) == 0 {
return nil, nil
}
return records[0], nil
}
// GetAgentByDefinitionID retrieves an agent definition by definition_id within the caller's scope,
// giving personal definitions precedence over organization definitions when shadowed.
func (r *DefinitionsRepo) GetAgentByDefinitionID(ctx context.Context, ident authctx.Identity, defID string) (domain.Record, error) {
q := fmt.Sprintf(
`SELECT %s FROM agent_definitions
WHERE definition_id = $1::text
AND org_id = $2::uuid
AND (visibility = 'organization' OR (visibility = 'personal' AND owner_user_id = $3::uuid))
ORDER BY CASE WHEN visibility = 'personal' THEN 1 ELSE 2 END
LIMIT 1`,
agentDefinitionColumns)
rows, err := r.db.Query(ctx, q, defID, ident.OrgID, ident.UserID)
if err != nil {
return nil, translate(err)
}
defer rows.Close()
records, err := collect(rows)
if err != nil {
return nil, translate(err)
}
if len(records) == 0 {
return nil, nil
}
return records[0], nil
}
// InsertAgent writes an agent definition and returns the stored row.
func (r *DefinitionsRepo) InsertAgent(ctx context.Context, ident authctx.Identity, in AgentInsertInput) (domain.Record, error) {
q := fmt.Sprintf(
`INSERT INTO agent_definitions (
definition_id, org_id, visibility, owner_user_id, created_by,
markdown, status, version, name, description, pages
) VALUES (
$1::text, $2::uuid, $3::text, $4::uuid, $5::uuid,
$6::text, $7::text, $8::integer, $9::text, $10::text, $11::text[]
) RETURNING %s`, agentDefinitionColumns)
pages := in.Pages
if pages == nil {
pages = []string{}
}
rows, err := r.db.Query(ctx, q,
in.DefinitionID, in.OrgID, in.Visibility, in.OwnerUserID, in.CreatedBy,
in.Markdown, in.Status, in.Version, in.Name, in.Description, pages)
if err != nil {
return nil, translate(err)
}
defer rows.Close()
records, err := collect(rows)
if err != nil {
return nil, translate(err)
}
if len(records) == 0 {
return nil, fmt.Errorf("insert agent_definitions returned no row")
}
return records[0], nil
}
// UpdateAgent updates fields on an agent definition and returns the full updated record.
func (r *DefinitionsRepo) UpdateAgent(ctx context.Context, ident authctx.Identity, id string, in AgentUpdateInput) (domain.Record, error) {
b := &builder{}
sets := []string{"updated_date = now()"}
if in.DefinitionID != nil {
sets = append(sets, fmt.Sprintf("definition_id = %s::text", b.add(*in.DefinitionID)))
}
if in.Name != nil {
sets = append(sets, fmt.Sprintf("name = %s::text", b.add(*in.Name)))
}
if in.Description != nil {
sets = append(sets, fmt.Sprintf("description = %s::text", b.add(*in.Description)))
}
if in.Status != nil {
sets = append(sets, fmt.Sprintf("status = %s::text", b.add(*in.Status)))
}
if in.Version != nil {
sets = append(sets, fmt.Sprintf("version = %s::integer", b.add(*in.Version)))
}
if in.Pages != nil {
sets = append(sets, fmt.Sprintf("pages = %s::text[]", b.add(in.Pages)))
}
if in.Markdown != nil {
sets = append(sets, fmt.Sprintf("markdown = %s::text", b.add(*in.Markdown)))
}
if in.Visibility != nil {
sets = append(sets, fmt.Sprintf("visibility = %s::text", b.add(*in.Visibility)))
sets = append(sets, fmt.Sprintf("owner_user_id = %s::uuid", b.add(in.OwnerUserID)))
}
b.where = append(b.where, fmt.Sprintf("id = %s::uuid", b.add(id)))
b.where = append(b.where, fmt.Sprintf("org_id = %s::uuid", b.add(ident.OrgID)))
b.where = append(b.where, fmt.Sprintf("(visibility = 'organization' OR (visibility = 'personal' AND owner_user_id = %s::uuid))", b.add(ident.UserID)))
q := fmt.Sprintf("UPDATE agent_definitions SET %s%s RETURNING %s",
strings.Join(sets, ", "), b.clause(), agentDefinitionColumns)
rows, err := r.db.Query(ctx, q, b.args...)
if err != nil {
return nil, translate(err)
}
defer rows.Close()
records, err := collect(rows)
if err != nil {
return nil, translate(err)
}
if len(records) == 0 {
return nil, nil
}
return records[0], nil
}
// DeleteAgent removes an agent definition matching id and access scope.
func (r *DefinitionsRepo) DeleteAgent(ctx context.Context, ident authctx.Identity, id string) (int64, error) {
q := `DELETE FROM agent_definitions
WHERE id = $1::uuid
AND org_id = $2::uuid
AND (visibility = 'organization' OR (visibility = 'personal' AND owner_user_id = $3::uuid))`
tag, err := r.db.Exec(ctx, q, id, ident.OrgID, ident.UserID)
if err != nil {
return 0, translate(err)
}
return tag.RowsAffected(), nil
}
/* ── Skills ─────────────────────────────────────────────────────────────── */
// ListSkills lists skill definitions matching the criteria with tenant isolation.
func (r *DefinitionsRepo) ListSkills(ctx context.Context, ident authctx.Identity, p DefinitionListParams) ([]domain.Record, int, error) {
b := &builder{}
b.where = append(b.where, fmt.Sprintf("org_id = %s::uuid", b.add(ident.OrgID)))
switch p.Visibility {
case "personal":
b.where = append(b.where, fmt.Sprintf("(visibility = 'personal' AND owner_user_id = %s::uuid)", b.add(ident.UserID)))
case "organization":
b.where = append(b.where, "visibility = 'organization'")
default:
b.where = append(b.where, fmt.Sprintf("(visibility = 'organization' OR (visibility = 'personal' AND owner_user_id = %s::uuid))", b.add(ident.UserID)))
}
if p.Status != "" {
b.where = append(b.where, fmt.Sprintf("status = %s::text", b.add(p.Status)))
}
if p.DefinitionID != "" {
b.where = append(b.where, fmt.Sprintf("definition_id = %s::text", b.add(p.DefinitionID)))
}
countSQL := "SELECT count(*)::int FROM skill_definitions" + b.clause()
var total int
if err := r.db.QueryRow(ctx, countSQL, b.args...).Scan(&total); err != nil {
return nil, 0, translate(err)
}
sortCol := "created_date"
switch p.Sort {
case "created_date", "updated_date", "name", "definition_id", "status":
sortCol = p.Sort
}
dir := "ASC"
if p.Desc {
dir = "DESC"
}
orderClause := fmt.Sprintf(" ORDER BY %s %s, id %s", sortCol, dir, dir)
limit := p.Limit
if limit <= 0 {
limit = 100
}
offset := p.Offset
if offset < 0 {
offset = 0
}
limitClause := fmt.Sprintf(" LIMIT %s OFFSET %s", b.add(limit), b.add(offset))
querySQL := "SELECT " + skillDefinitionColumns + " FROM skill_definitions" + b.clause() + orderClause + limitClause
rows, err := r.db.Query(ctx, querySQL, b.args...)
if err != nil {
return nil, 0, translate(err)
}
defer rows.Close()
records, err := collect(rows)
if err != nil {
return nil, 0, translate(err)
}
return records, total, nil
}
// GetSkill retrieves one skill definition by id within the caller's tenant and ownership scope.
func (r *DefinitionsRepo) GetSkill(ctx context.Context, ident authctx.Identity, id string) (domain.Record, error) {
q := fmt.Sprintf(
`SELECT %s FROM skill_definitions
WHERE id = $1::uuid
AND org_id = $2::uuid
AND (visibility = 'organization' OR (visibility = 'personal' AND owner_user_id = $3::uuid))
LIMIT 1`,
skillDefinitionColumns)
rows, err := r.db.Query(ctx, q, id, ident.OrgID, ident.UserID)
if err != nil {
return nil, translate(err)
}
defer rows.Close()
records, err := collect(rows)
if err != nil {
return nil, translate(err)
}
if len(records) == 0 {
return nil, nil
}
return records[0], nil
}
// GetSkillByDefinitionID retrieves a skill definition by definition_id within the caller's scope,
// giving personal definitions precedence over organization definitions when shadowed.
func (r *DefinitionsRepo) GetSkillByDefinitionID(ctx context.Context, ident authctx.Identity, defID string) (domain.Record, error) {
q := fmt.Sprintf(
`SELECT %s FROM skill_definitions
WHERE definition_id = $1::text
AND org_id = $2::uuid
AND (visibility = 'organization' OR (visibility = 'personal' AND owner_user_id = $3::uuid))
ORDER BY CASE WHEN visibility = 'personal' THEN 1 ELSE 2 END
LIMIT 1`,
skillDefinitionColumns)
rows, err := r.db.Query(ctx, q, defID, ident.OrgID, ident.UserID)
if err != nil {
return nil, translate(err)
}
defer rows.Close()
records, err := collect(rows)
if err != nil {
return nil, translate(err)
}
if len(records) == 0 {
return nil, nil
}
return records[0], nil
}
// InsertSkill writes a skill definition and returns the stored row.
func (r *DefinitionsRepo) InsertSkill(ctx context.Context, ident authctx.Identity, in SkillInsertInput) (domain.Record, error) {
q := fmt.Sprintf(
`INSERT INTO skill_definitions (
definition_id, org_id, visibility, owner_user_id, created_by,
markdown, status, name, description, pages
) VALUES (
$1::text, $2::uuid, $3::text, $4::uuid, $5::uuid,
$6::text, $7::text, $8::text, $9::text, $10::text[]
) RETURNING %s`, skillDefinitionColumns)
pages := in.Pages
if pages == nil {
pages = []string{}
}
rows, err := r.db.Query(ctx, q,
in.DefinitionID, in.OrgID, in.Visibility, in.OwnerUserID, in.CreatedBy,
in.Markdown, in.Status, in.Name, in.Description, pages)
if err != nil {
return nil, translate(err)
}
defer rows.Close()
records, err := collect(rows)
if err != nil {
return nil, translate(err)
}
if len(records) == 0 {
return nil, fmt.Errorf("insert skill_definitions returned no row")
}
return records[0], nil
}
// UpdateSkill updates fields on a skill definition and returns the full updated record.
func (r *DefinitionsRepo) UpdateSkill(ctx context.Context, ident authctx.Identity, id string, in SkillUpdateInput) (domain.Record, error) {
b := &builder{}
sets := []string{"updated_date = now()"}
if in.DefinitionID != nil {
sets = append(sets, fmt.Sprintf("definition_id = %s::text", b.add(*in.DefinitionID)))
}
if in.Name != nil {
sets = append(sets, fmt.Sprintf("name = %s::text", b.add(*in.Name)))
}
if in.Description != nil {
sets = append(sets, fmt.Sprintf("description = %s::text", b.add(*in.Description)))
}
if in.Status != nil {
sets = append(sets, fmt.Sprintf("status = %s::text", b.add(*in.Status)))
}
if in.Pages != nil {
sets = append(sets, fmt.Sprintf("pages = %s::text[]", b.add(in.Pages)))
}
if in.Markdown != nil {
sets = append(sets, fmt.Sprintf("markdown = %s::text", b.add(*in.Markdown)))
}
if in.Visibility != nil {
sets = append(sets, fmt.Sprintf("visibility = %s::text", b.add(*in.Visibility)))
sets = append(sets, fmt.Sprintf("owner_user_id = %s::uuid", b.add(in.OwnerUserID)))
}
b.where = append(b.where, fmt.Sprintf("id = %s::uuid", b.add(id)))
b.where = append(b.where, fmt.Sprintf("org_id = %s::uuid", b.add(ident.OrgID)))
b.where = append(b.where, fmt.Sprintf("(visibility = 'organization' OR (visibility = 'personal' AND owner_user_id = %s::uuid))", b.add(ident.UserID)))
q := fmt.Sprintf("UPDATE skill_definitions SET %s%s RETURNING %s",
strings.Join(sets, ", "), b.clause(), skillDefinitionColumns)
rows, err := r.db.Query(ctx, q, b.args...)
if err != nil {
return nil, translate(err)
}
defer rows.Close()
records, err := collect(rows)
if err != nil {
return nil, translate(err)
}
if len(records) == 0 {
return nil, nil
}
return records[0], nil
}
// DeleteSkill removes a skill definition matching id and access scope.
func (r *DefinitionsRepo) DeleteSkill(ctx context.Context, ident authctx.Identity, id string) (int64, error) {
q := `DELETE FROM skill_definitions
WHERE id = $1::uuid
AND org_id = $2::uuid
AND (visibility = 'organization' OR (visibility = 'personal' AND owner_user_id = $3::uuid))`
tag, err := r.db.Exec(ctx, q, id, ident.OrgID, ident.UserID)
if err != nil {
return 0, translate(err)
}
return tag.RowsAffected(), nil
}