package connections import ( "database/sql" "encoding/json" "fmt" "log/slog" "strings" "time" ) type Group struct { ID int64 `json:"id"` Name string `json:"name"` ParentID *int64 `json:"parentId"` SortOrder int `json:"sortOrder"` Icon string `json:"icon"` CreatedAt time.Time `json:"createdAt"` Children []Group `json:"children,omitempty"` } type Connection struct { ID int64 `json:"id"` Name string `json:"name"` Hostname string `json:"hostname"` Port int `json:"port"` Protocol string `json:"protocol"` GroupID *int64 `json:"groupId"` CredentialID *int64 `json:"credentialId"` Color string `json:"color"` Tags []string `json:"tags"` Notes string `json:"notes"` Options string `json:"options"` SortOrder int `json:"sortOrder"` LastConnected *time.Time `json:"lastConnected"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } type CreateConnectionInput struct { Name string `json:"name"` Hostname string `json:"hostname"` Port int `json:"port"` Protocol string `json:"protocol"` GroupID *int64 `json:"groupId"` CredentialID *int64 `json:"credentialId"` Color string `json:"color"` Tags []string `json:"tags"` Notes string `json:"notes"` Options string `json:"options"` } type UpdateConnectionInput struct { Name *string `json:"name"` Hostname *string `json:"hostname"` Port *int `json:"port"` GroupID *int64 `json:"groupId"` CredentialID *int64 `json:"credentialId"` Color *string `json:"color"` Tags []string `json:"tags"` Notes *string `json:"notes"` Options *string `json:"options"` } type ConnectionService struct { db *sql.DB } func NewConnectionService(db *sql.DB) *ConnectionService { return &ConnectionService{db: db} } // ---------- Group CRUD ---------- func (s *ConnectionService) CreateGroup(name string, parentID *int64) (*Group, error) { result, err := s.db.Exec( "INSERT INTO groups (name, parent_id) VALUES (?, ?)", name, parentID, ) if err != nil { return nil, fmt.Errorf("create group: %w", err) } id, err := result.LastInsertId() if err != nil { return nil, fmt.Errorf("get group id: %w", err) } var g Group var icon sql.NullString err = s.db.QueryRow( "SELECT id, name, parent_id, sort_order, icon, created_at FROM groups WHERE id = ?", id, ).Scan(&g.ID, &g.Name, &g.ParentID, &g.SortOrder, &icon, &g.CreatedAt) if err != nil { return nil, fmt.Errorf("get created group: %w", err) } if icon.Valid { g.Icon = icon.String } return &g, nil } func (s *ConnectionService) ListGroups() ([]Group, error) { rows, err := s.db.Query( "SELECT id, name, parent_id, sort_order, icon, created_at FROM groups ORDER BY sort_order, name", ) if err != nil { return nil, fmt.Errorf("list groups: %w", err) } defer rows.Close() groupMap := make(map[int64]*Group) var allGroups []*Group for rows.Next() { var g Group var icon sql.NullString if err := rows.Scan(&g.ID, &g.Name, &g.ParentID, &g.SortOrder, &icon, &g.CreatedAt); err != nil { return nil, fmt.Errorf("scan group: %w", err) } if icon.Valid { g.Icon = icon.String } groupMap[g.ID] = &g allGroups = append(allGroups, &g) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate groups: %w", err) } // Build tree: attach children to parents, collect roots var roots []Group for _, g := range allGroups { if g.ParentID != nil { if parent, ok := groupMap[*g.ParentID]; ok { parent.Children = append(parent.Children, *g) } } else { roots = append(roots, *g) } } // Re-attach children to root copies (since we copied into roots) for i := range roots { if orig, ok := groupMap[roots[i].ID]; ok { roots[i].Children = orig.Children } } if roots == nil { roots = []Group{} } return roots, nil } func (s *ConnectionService) DeleteGroup(id int64) error { _, err := s.db.Exec("DELETE FROM groups WHERE id = ?", id) if err != nil { return fmt.Errorf("delete group: %w", err) } return nil } func (s *ConnectionService) RenameGroup(id int64, name string) error { _, err := s.db.Exec("UPDATE groups SET name = ? WHERE id = ?", name, id) if err != nil { return fmt.Errorf("rename group: %w", err) } return nil } // ---------- Connection CRUD ---------- func (s *ConnectionService) CreateConnection(input CreateConnectionInput) (*Connection, error) { tags, err := json.Marshal(input.Tags) if err != nil { return nil, fmt.Errorf("marshal tags: %w", err) } if input.Tags == nil { tags = []byte("[]") } options := input.Options if options == "" { options = "{}" } result, err := s.db.Exec( `INSERT INTO connections (name, hostname, port, protocol, group_id, credential_id, color, tags, notes, options) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, input.Name, input.Hostname, input.Port, input.Protocol, input.GroupID, input.CredentialID, input.Color, string(tags), input.Notes, options, ) if err != nil { return nil, fmt.Errorf("create connection: %w", err) } id, err := result.LastInsertId() if err != nil { return nil, fmt.Errorf("get connection id: %w", err) } return s.GetConnection(id) } func (s *ConnectionService) GetConnection(id int64) (*Connection, error) { row := s.db.QueryRow( `SELECT id, name, hostname, port, protocol, group_id, credential_id, color, tags, notes, options, sort_order, last_connected, created_at, updated_at FROM connections WHERE id = ?`, id, ) var c Connection var tagsJSON string var color, notes, options sql.NullString var lastConnected sql.NullTime err := row.Scan( &c.ID, &c.Name, &c.Hostname, &c.Port, &c.Protocol, &c.GroupID, &c.CredentialID, &color, &tagsJSON, ¬es, &options, &c.SortOrder, &lastConnected, &c.CreatedAt, &c.UpdatedAt, ) if err != nil { return nil, fmt.Errorf("get connection: %w", err) } if color.Valid { c.Color = color.String } if notes.Valid { c.Notes = notes.String } if options.Valid { c.Options = options.String } if lastConnected.Valid { c.LastConnected = &lastConnected.Time } if err := json.Unmarshal([]byte(tagsJSON), &c.Tags); err != nil { c.Tags = []string{} } if c.Tags == nil { c.Tags = []string{} } return &c, nil } func (s *ConnectionService) ListConnections() ([]Connection, error) { rows, err := s.db.Query( `SELECT id, name, hostname, port, protocol, group_id, credential_id, color, tags, notes, options, sort_order, last_connected, created_at, updated_at FROM connections ORDER BY sort_order, name`, ) if err != nil { return nil, fmt.Errorf("list connections: %w", err) } defer rows.Close() return scanConnections(rows) } // scanConnections is a shared helper used by ListConnections and (later) Search. func scanConnections(rows *sql.Rows) ([]Connection, error) { var conns []Connection for rows.Next() { var c Connection var tagsJSON string var color, notes, options sql.NullString var lastConnected sql.NullTime if err := rows.Scan( &c.ID, &c.Name, &c.Hostname, &c.Port, &c.Protocol, &c.GroupID, &c.CredentialID, &color, &tagsJSON, ¬es, &options, &c.SortOrder, &lastConnected, &c.CreatedAt, &c.UpdatedAt, ); err != nil { return nil, fmt.Errorf("scan connection: %w", err) } if color.Valid { c.Color = color.String } if notes.Valid { c.Notes = notes.String } if options.Valid { c.Options = options.String } if lastConnected.Valid { c.LastConnected = &lastConnected.Time } if err := json.Unmarshal([]byte(tagsJSON), &c.Tags); err != nil { c.Tags = []string{} } if c.Tags == nil { c.Tags = []string{} } conns = append(conns, c) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate connections: %w", err) } if conns == nil { conns = []Connection{} } return conns, nil } func (s *ConnectionService) UpdateConnection(id int64, input UpdateConnectionInput) (*Connection, error) { slog.Info("UpdateConnection called", "id", id, "name", input.Name, "credentialID", input.CredentialID, "groupID", input.GroupID, ) setClauses := []string{"updated_at = CURRENT_TIMESTAMP"} args := []interface{}{} if input.Name != nil { setClauses = append(setClauses, "name = ?") args = append(args, *input.Name) } if input.Hostname != nil { setClauses = append(setClauses, "hostname = ?") args = append(args, *input.Hostname) } if input.Port != nil { setClauses = append(setClauses, "port = ?") args = append(args, *input.Port) } if input.GroupID != nil { setClauses = append(setClauses, "group_id = ?") args = append(args, *input.GroupID) } // Always update credential_id — nil clears it, non-nil sets it. // Unlike other fields, credential assignment is explicit on every save. setClauses = append(setClauses, "credential_id = ?") if input.CredentialID != nil { args = append(args, *input.CredentialID) } else { args = append(args, nil) } if input.Tags != nil { tags, _ := json.Marshal(input.Tags) setClauses = append(setClauses, "tags = ?") args = append(args, string(tags)) } if input.Notes != nil { setClauses = append(setClauses, "notes = ?") args = append(args, *input.Notes) } if input.Color != nil { setClauses = append(setClauses, "color = ?") args = append(args, *input.Color) } if input.Options != nil { setClauses = append(setClauses, "options = ?") args = append(args, *input.Options) } args = append(args, id) query := fmt.Sprintf("UPDATE connections SET %s WHERE id = ?", strings.Join(setClauses, ", ")) if _, err := s.db.Exec(query, args...); err != nil { return nil, fmt.Errorf("update connection: %w", err) } return s.GetConnection(id) } func (s *ConnectionService) DeleteConnection(id int64) error { _, err := s.db.Exec("DELETE FROM connections WHERE id = ?", id) if err != nil { return fmt.Errorf("delete connection: %w", err) } return nil }