mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-12 20:15:42 +00:00
Deleting a team failed with SQLSTATE 23514 whenever it owned a team-scoped vault document. The vault_documents.team_id FK is ON DELETE SET NULL; when team_id became NULL the vault_docs_team_null_scope_fix() trigger (migration 000043) unconditionally set scope='personal'. Team docs have agent_id IS NULL, so the resulting (personal, agent_id NULL) row violates the vault_documents_scope_consistency CHECK and aborts the whole delete. PostgreSQL: migration 000089 replaces the trigger function to pick a valid target scope by ownership, and only rewrite genuinely team-scoped rows so scope='custom' docs that merely carry a team_id are preserved: agent_id IS NOT NULL -> 'personal' (defensive: legacy dirty rows) agent_id IS NULL -> 'shared' (normal team docs) SQLite: the same class of bug exists (FK SET NULL leaves scope='team' with a NULL team_id and aborts on the CHECK), but SQLite fires no trigger during the FK action, so a DB-level fix is impossible. SQLiteTeamStore.DeleteTeam now converts team-scoped docs in the same transaction before removing the team, scoped by tenant_id in the tenant path to keep tenant isolation. Adds regression tests on both engines and bumps RequiredSchemaVersion to 89. SQLite needs no schema migration since the fix is in Go code.
536 lines
16 KiB
Go
536 lines
16 KiB
Go
//go:build sqlite || sqliteonly
|
|
|
|
package sqlitestore
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
// SQLiteTeamStore implements store.TeamStore backed by SQLite.
|
|
type SQLiteTeamStore struct {
|
|
db *sql.DB
|
|
}
|
|
|
|
// SetEmbeddingProvider is a no-op for SQLite — vector search not supported.
|
|
func (s *SQLiteTeamStore) SetEmbeddingProvider(_ store.EmbeddingProvider) {}
|
|
|
|
func NewSQLiteTeamStore(db *sql.DB) *SQLiteTeamStore {
|
|
return &SQLiteTeamStore{db: db}
|
|
}
|
|
|
|
const teamSelectCols = `id, name, lead_agent_id, description, status, settings, created_by, created_at, updated_at`
|
|
|
|
// ============================================================
|
|
// Team CRUD
|
|
// ============================================================
|
|
|
|
func (s *SQLiteTeamStore) CreateTeam(ctx context.Context, team *store.TeamData) error {
|
|
if team.ID == uuid.Nil {
|
|
team.ID = store.GenNewID()
|
|
}
|
|
now := time.Now()
|
|
team.CreatedAt = now
|
|
team.UpdatedAt = now
|
|
|
|
settings := team.Settings
|
|
if len(settings) == 0 {
|
|
settings = json.RawMessage(`{}`)
|
|
}
|
|
|
|
tenantID := store.TenantIDFromContext(ctx)
|
|
if tenantID == uuid.Nil {
|
|
tenantID = store.MasterTenantID
|
|
}
|
|
|
|
_, err := s.db.ExecContext(ctx,
|
|
`INSERT INTO agent_teams (id, name, lead_agent_id, description, status, settings, created_by, created_at, updated_at, tenant_id)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
team.ID, team.Name, team.LeadAgentID, team.Description,
|
|
team.Status, settings, team.CreatedBy, now, now, tenantID,
|
|
)
|
|
return err
|
|
}
|
|
|
|
func (s *SQLiteTeamStore) GetTeam(ctx context.Context, teamID uuid.UUID) (*store.TeamData, error) {
|
|
if store.IsCrossTenant(ctx) {
|
|
row := s.db.QueryRowContext(ctx,
|
|
`SELECT `+teamSelectCols+` FROM agent_teams WHERE id = ?`, teamID)
|
|
return scanTeamRow(row)
|
|
}
|
|
tenantID := store.TenantIDFromContext(ctx)
|
|
if tenantID == uuid.Nil {
|
|
return nil, nil
|
|
}
|
|
row := s.db.QueryRowContext(ctx,
|
|
`SELECT `+teamSelectCols+` FROM agent_teams WHERE id = ? AND tenant_id = ?`, teamID, tenantID)
|
|
d, err := scanTeamRow(row)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, nil
|
|
}
|
|
return d, err
|
|
}
|
|
|
|
func (s *SQLiteTeamStore) UpdateTeam(ctx context.Context, teamID uuid.UUID, updates map[string]any) error {
|
|
if store.IsCrossTenant(ctx) {
|
|
return execMapUpdate(ctx, s.db, "agent_teams", teamID, updates)
|
|
}
|
|
tid := store.TenantIDFromContext(ctx)
|
|
if tid == uuid.Nil {
|
|
return fmt.Errorf("tenant_id required for update")
|
|
}
|
|
return execMapUpdateWhereTenant(ctx, s.db, "agent_teams", updates, teamID, tid)
|
|
}
|
|
|
|
func (s *SQLiteTeamStore) DeleteTeam(ctx context.Context, teamID uuid.UUID) error {
|
|
crossTenant := store.IsCrossTenant(ctx)
|
|
tid := store.TenantIDFromContext(ctx)
|
|
if !crossTenant && tid == uuid.Nil {
|
|
return fmt.Errorf("tenant_id required for delete")
|
|
}
|
|
|
|
// Team deletion must not fail when the team owns vault documents (issue #1077).
|
|
// The team_id FK is ON DELETE SET NULL, but SQLite fires no scope-fixing trigger
|
|
// during the FK action, so a team-scoped doc would be left as scope='team' with a
|
|
// NULL team_id and abort the delete on the vault_documents_scope_consistency CHECK.
|
|
// Convert team-scoped docs first (agent_id IS NULL -> 'shared'; the CASE guards
|
|
// any legacy 'team' rows that carry an agent_id). Custom-scope docs are left as-is;
|
|
// the FK SET NULL clears their team_id and 'custom' has no consistency constraint.
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
const fixScopeSQL = `UPDATE vault_documents
|
|
SET scope = CASE WHEN agent_id IS NOT NULL THEN 'personal' ELSE 'shared' END,
|
|
team_id = NULL
|
|
WHERE team_id = ? AND scope = 'team'`
|
|
if crossTenant {
|
|
if _, err := tx.ExecContext(ctx, fixScopeSQL, teamID); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `DELETE FROM agent_teams WHERE id = ?`, teamID); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
if _, err := tx.ExecContext(ctx, fixScopeSQL+` AND tenant_id = ?`, teamID, tid); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `DELETE FROM agent_teams WHERE id = ? AND tenant_id = ?`, teamID, tid); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
func (s *SQLiteTeamStore) ListTeams(ctx context.Context) ([]store.TeamData, error) {
|
|
var tenantFilter string
|
|
var queryArgs []any
|
|
if !store.IsCrossTenant(ctx) {
|
|
tenantID := store.TenantIDFromContext(ctx)
|
|
if tenantID == uuid.Nil {
|
|
return nil, nil
|
|
}
|
|
tenantFilter = " WHERE t.tenant_id = ?"
|
|
queryArgs = append(queryArgs, tenantID)
|
|
}
|
|
|
|
rows, err := s.db.QueryContext(ctx,
|
|
`SELECT t.id, t.name, t.lead_agent_id, t.description, t.status, t.settings, t.created_by, t.created_at, t.updated_at,
|
|
COALESCE(a.agent_key, '') AS lead_agent_key,
|
|
COALESCE(a.display_name, '') AS lead_display_name
|
|
FROM agent_teams t
|
|
LEFT JOIN agents a ON a.id = t.lead_agent_id`+tenantFilter+`
|
|
ORDER BY t.created_at`, queryArgs...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var teams []store.TeamData
|
|
teamIndex := map[uuid.UUID]int{}
|
|
for rows.Next() {
|
|
var d store.TeamData
|
|
var desc sql.NullString
|
|
createdAt, updatedAt := scanTimePair()
|
|
if err := rows.Scan(
|
|
&d.ID, &d.Name, &d.LeadAgentID, &desc, &d.Status,
|
|
&d.Settings, &d.CreatedBy, createdAt, updatedAt,
|
|
&d.LeadAgentKey, &d.LeadDisplayName,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
d.CreatedAt = createdAt.Time
|
|
d.UpdatedAt = updatedAt.Time
|
|
if desc.Valid {
|
|
d.Description = desc.String
|
|
}
|
|
teamIndex[d.ID] = len(teams)
|
|
teams = append(teams, d)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(teams) > 0 {
|
|
mRows, err := s.db.QueryContext(ctx,
|
|
`SELECT m.team_id, m.agent_id, m.role, m.joined_at,
|
|
COALESCE(a.agent_key, '') AS agent_key,
|
|
COALESCE(a.display_name, '') AS display_name,
|
|
COALESCE(a.frontmatter, '') AS frontmatter,
|
|
COALESCE(a.emoji, '') AS emoji
|
|
FROM agent_team_members m
|
|
JOIN agents a ON a.id = m.agent_id
|
|
WHERE a.status = 'active'
|
|
ORDER BY m.joined_at`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer mRows.Close()
|
|
|
|
for mRows.Next() {
|
|
var m store.TeamMemberData
|
|
var joinedAt sqliteTime
|
|
if err := mRows.Scan(&m.TeamID, &m.AgentID, &m.Role, &joinedAt, &m.AgentKey, &m.DisplayName, &m.Frontmatter, &m.Emoji); err != nil {
|
|
return nil, err
|
|
}
|
|
m.JoinedAt = joinedAt.Time
|
|
if idx, ok := teamIndex[m.TeamID]; ok {
|
|
teams[idx].Members = append(teams[idx].Members, m)
|
|
teams[idx].MemberCount++
|
|
}
|
|
}
|
|
if err := mRows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
return teams, nil
|
|
}
|
|
|
|
// ============================================================
|
|
// Members
|
|
// ============================================================
|
|
|
|
func (s *SQLiteTeamStore) AddMember(ctx context.Context, teamID, agentID uuid.UUID, role string) error {
|
|
_, err := s.db.ExecContext(ctx,
|
|
`INSERT INTO agent_team_members (team_id, agent_id, role, joined_at, tenant_id)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT (team_id, agent_id) DO UPDATE SET role = excluded.role`,
|
|
teamID, agentID, role, time.Now(), tenantIDForInsert(ctx),
|
|
)
|
|
return err
|
|
}
|
|
|
|
func (s *SQLiteTeamStore) RemoveMember(ctx context.Context, teamID, agentID uuid.UUID) error {
|
|
_, err := s.db.ExecContext(ctx,
|
|
`DELETE FROM agent_team_members WHERE team_id = ? AND agent_id = ?`,
|
|
teamID, agentID,
|
|
)
|
|
return err
|
|
}
|
|
|
|
func (s *SQLiteTeamStore) ListMembers(ctx context.Context, teamID uuid.UUID) ([]store.TeamMemberData, error) {
|
|
q := `SELECT m.team_id, m.agent_id, m.role, m.joined_at,
|
|
COALESCE(a.agent_key, '') AS agent_key,
|
|
COALESCE(a.display_name, '') AS display_name,
|
|
COALESCE(a.frontmatter, '') AS frontmatter,
|
|
COALESCE(a.emoji, '') AS emoji
|
|
FROM agent_team_members m
|
|
JOIN agents a ON a.id = m.agent_id
|
|
JOIN agent_teams at2 ON at2.id = m.team_id
|
|
WHERE m.team_id = ? AND a.status = 'active'`
|
|
args := []any{teamID}
|
|
|
|
if !store.IsCrossTenant(ctx) {
|
|
tid := store.TenantIDFromContext(ctx)
|
|
if tid != uuid.Nil {
|
|
q += " AND at2.tenant_id = ?"
|
|
args = append(args, tid)
|
|
}
|
|
}
|
|
q += ` ORDER BY m.joined_at`
|
|
|
|
rows, err := s.db.QueryContext(ctx, q, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var members []store.TeamMemberData
|
|
for rows.Next() {
|
|
var d store.TeamMemberData
|
|
var joinedAt sqliteTime
|
|
if err := rows.Scan(
|
|
&d.TeamID, &d.AgentID, &d.Role, &joinedAt,
|
|
&d.AgentKey, &d.DisplayName, &d.Frontmatter, &d.Emoji,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
d.JoinedAt = joinedAt.Time
|
|
members = append(members, d)
|
|
}
|
|
return members, rows.Err()
|
|
}
|
|
|
|
func (s *SQLiteTeamStore) ListIdleMembers(ctx context.Context, teamID uuid.UUID) ([]store.TeamMemberData, error) {
|
|
q := `SELECT m.team_id, m.agent_id, m.role, m.joined_at,
|
|
COALESCE(a.agent_key, '') AS agent_key,
|
|
COALESCE(a.display_name, '') AS display_name,
|
|
COALESCE(a.frontmatter, '') AS frontmatter,
|
|
COALESCE(a.emoji, '') AS emoji
|
|
FROM agent_team_members m
|
|
JOIN agents a ON a.id = m.agent_id
|
|
JOIN agent_teams at2 ON at2.id = m.team_id
|
|
WHERE m.team_id = ? AND a.status = 'active' AND m.role != ?
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM team_tasks tt
|
|
WHERE tt.owner_agent_id = m.agent_id AND tt.team_id = ? AND tt.status = ?
|
|
)`
|
|
args := []any{teamID, store.TeamRoleLead, teamID, store.TeamTaskStatusInProgress}
|
|
|
|
if !store.IsCrossTenant(ctx) {
|
|
tid := store.TenantIDFromContext(ctx)
|
|
if tid != uuid.Nil {
|
|
q += " AND at2.tenant_id = ?"
|
|
args = append(args, tid)
|
|
}
|
|
}
|
|
q += ` ORDER BY m.joined_at`
|
|
|
|
rows, err := s.db.QueryContext(ctx, q, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var members []store.TeamMemberData
|
|
for rows.Next() {
|
|
var d store.TeamMemberData
|
|
var joinedAt sqliteTime
|
|
if err := rows.Scan(
|
|
&d.TeamID, &d.AgentID, &d.Role, &joinedAt,
|
|
&d.AgentKey, &d.DisplayName, &d.Frontmatter, &d.Emoji,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
d.JoinedAt = joinedAt.Time
|
|
members = append(members, d)
|
|
}
|
|
return members, rows.Err()
|
|
}
|
|
|
|
func (s *SQLiteTeamStore) GetTeamForAgent(ctx context.Context, agentID uuid.UUID) (*store.TeamData, error) {
|
|
q := `SELECT t.id, t.name, t.lead_agent_id, t.description, t.status, t.settings, t.created_by, t.created_at, t.updated_at
|
|
FROM agent_teams t
|
|
WHERE (
|
|
t.lead_agent_id = ?
|
|
OR EXISTS (SELECT 1 FROM agent_team_members m WHERE m.team_id = t.id AND m.agent_id = ?)
|
|
) AND t.status = ?`
|
|
args := []any{agentID, agentID, store.TeamStatusActive}
|
|
|
|
if !store.IsCrossTenant(ctx) {
|
|
tid := store.TenantIDFromContext(ctx)
|
|
if tid != uuid.Nil {
|
|
q += " AND t.tenant_id = ?"
|
|
args = append(args, tid)
|
|
}
|
|
}
|
|
q += ` ORDER BY (t.lead_agent_id = ?) DESC LIMIT 1`
|
|
args = append(args, agentID)
|
|
|
|
row := s.db.QueryRowContext(ctx, q, args...)
|
|
d, err := scanTeamRow(row)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, nil
|
|
}
|
|
return d, err
|
|
}
|
|
|
|
func (s *SQLiteTeamStore) KnownUserIDs(ctx context.Context, teamID uuid.UUID, limit int) ([]string, error) {
|
|
if limit <= 0 {
|
|
limit = 100
|
|
}
|
|
q := `SELECT DISTINCT s.user_id
|
|
FROM sessions s
|
|
JOIN agent_team_members m ON m.agent_id = s.agent_id
|
|
JOIN agent_teams at2 ON at2.id = m.team_id
|
|
WHERE m.team_id = ? AND s.user_id != ''`
|
|
args := []any{teamID}
|
|
|
|
if !store.IsCrossTenant(ctx) {
|
|
tid := store.TenantIDFromContext(ctx)
|
|
if tid != uuid.Nil {
|
|
q += " AND at2.tenant_id = ?"
|
|
args = append(args, tid)
|
|
}
|
|
}
|
|
q += ` ORDER BY s.user_id LIMIT ?`
|
|
args = append(args, limit)
|
|
|
|
rows, err := s.db.QueryContext(ctx, q, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var users []string
|
|
for rows.Next() {
|
|
var uid string
|
|
if err := rows.Scan(&uid); err != nil {
|
|
return nil, err
|
|
}
|
|
users = append(users, uid)
|
|
}
|
|
return users, rows.Err()
|
|
}
|
|
|
|
// ============================================================
|
|
// Team user grants
|
|
// ============================================================
|
|
|
|
func (s *SQLiteTeamStore) GrantTeamAccess(ctx context.Context, teamID uuid.UUID, userID, role, grantedBy string) error {
|
|
_, err := s.db.ExecContext(ctx,
|
|
`INSERT INTO team_user_grants (id, team_id, user_id, role, granted_by, created_at, tenant_id)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT (team_id, user_id) DO UPDATE SET role = excluded.role, granted_by = excluded.granted_by`,
|
|
store.GenNewID(), teamID, userID, role, grantedBy, time.Now(), tenantIDForInsert(ctx),
|
|
)
|
|
return err
|
|
}
|
|
|
|
func (s *SQLiteTeamStore) RevokeTeamAccess(ctx context.Context, teamID uuid.UUID, userID string) error {
|
|
tClause, tArgs, err := scopeClause(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = s.db.ExecContext(ctx,
|
|
`DELETE FROM team_user_grants WHERE team_id = ? AND user_id = ?`+tClause,
|
|
append([]any{teamID, userID}, tArgs...)...)
|
|
return err
|
|
}
|
|
|
|
func (s *SQLiteTeamStore) ListTeamGrants(ctx context.Context, teamID uuid.UUID) ([]store.TeamUserGrant, error) {
|
|
tClause, tArgs, err := scopeClause(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rows, err := s.db.QueryContext(ctx,
|
|
`SELECT id, team_id, user_id, role, COALESCE(granted_by, ''), created_at
|
|
FROM team_user_grants WHERE team_id = ?`+tClause+` ORDER BY created_at DESC`,
|
|
append([]any{teamID}, tArgs...)...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var result []store.TeamUserGrant
|
|
for rows.Next() {
|
|
var g store.TeamUserGrant
|
|
var createdAt sqliteTime
|
|
if err := rows.Scan(&g.ID, &g.TeamID, &g.UserID, &g.Role, &g.GrantedBy, &createdAt); err != nil {
|
|
return nil, err
|
|
}
|
|
g.CreatedAt = createdAt.Time
|
|
result = append(result, g)
|
|
}
|
|
return result, rows.Err()
|
|
}
|
|
|
|
func (s *SQLiteTeamStore) ListUserTeams(ctx context.Context, userID string) ([]store.TeamData, error) {
|
|
baseQuery := `SELECT ` + teamSelectCols + `
|
|
FROM agent_teams t
|
|
WHERE t.status = ?
|
|
AND EXISTS (SELECT 1 FROM team_user_grants g WHERE g.team_id = t.id AND g.user_id = ?)`
|
|
args := []any{store.TeamStatusActive, userID}
|
|
|
|
if !store.IsCrossTenant(ctx) {
|
|
tenantID := store.TenantIDFromContext(ctx)
|
|
if tenantID == uuid.Nil {
|
|
return nil, nil
|
|
}
|
|
baseQuery += ` AND t.tenant_id = ?`
|
|
args = append(args, tenantID)
|
|
}
|
|
baseQuery += ` ORDER BY t.created_at DESC`
|
|
|
|
rows, err := s.db.QueryContext(ctx, baseQuery, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var teams []store.TeamData
|
|
for rows.Next() {
|
|
var d store.TeamData
|
|
var desc sql.NullString
|
|
createdAt, updatedAt := scanTimePair()
|
|
if err := rows.Scan(
|
|
&d.ID, &d.Name, &d.LeadAgentID, &desc, &d.Status,
|
|
&d.Settings, &d.CreatedBy, createdAt, updatedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
d.CreatedAt = createdAt.Time
|
|
d.UpdatedAt = updatedAt.Time
|
|
if desc.Valid {
|
|
d.Description = desc.String
|
|
}
|
|
teams = append(teams, d)
|
|
}
|
|
return teams, rows.Err()
|
|
}
|
|
|
|
func (s *SQLiteTeamStore) HasTeamAccess(ctx context.Context, teamID uuid.UUID, userID string) (bool, error) {
|
|
tClause, tArgs, err := scopeClause(ctx)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
var exists bool
|
|
err = s.db.QueryRowContext(ctx,
|
|
`SELECT EXISTS(SELECT 1 FROM team_user_grants WHERE team_id = ? AND user_id = ?`+tClause+`)`,
|
|
append([]any{teamID, userID}, tArgs...)...,
|
|
).Scan(&exists)
|
|
return exists, err
|
|
}
|
|
|
|
// GetTeamUnscoped returns a team by ID without tenant filtering. Server-internal only.
|
|
func (s *SQLiteTeamStore) GetTeamUnscoped(ctx context.Context, id uuid.UUID) (*store.TeamData, error) {
|
|
row := s.db.QueryRowContext(ctx,
|
|
`SELECT `+teamSelectCols+` FROM agent_teams WHERE id = ?`, id)
|
|
return scanTeamRow(row)
|
|
}
|
|
|
|
// ============================================================
|
|
// Scan helpers
|
|
// ============================================================
|
|
|
|
func scanTeamRow(row *sql.Row) (*store.TeamData, error) {
|
|
var d store.TeamData
|
|
var desc sql.NullString
|
|
createdAt, updatedAt := scanTimePair()
|
|
err := row.Scan(
|
|
&d.ID, &d.Name, &d.LeadAgentID, &desc, &d.Status,
|
|
&d.Settings, &d.CreatedBy, createdAt, updatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
d.CreatedAt = createdAt.Time
|
|
d.UpdatedAt = updatedAt.Time
|
|
if desc.Valid {
|
|
d.Description = desc.String
|
|
}
|
|
return &d, nil
|
|
}
|