- Updated .env.example to include new configuration options for the UltiAI branding and API endpoints. - Enhanced Nginx configuration to support new API routes for the MCP and WebSocket connections. - Introduced sub-filters for branding adjustments in Nginx responses. - Added new JavaScript patch for API endpoint adjustments. - Implemented tests for new API functionalities and improved error handling in the AI gateway.
367 lines
9.0 KiB
Go
367 lines
9.0 KiB
Go
package admin
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
|
|
"github.com/ultisuite/ulti-backend/internal/api/query"
|
|
)
|
|
|
|
var ErrGroupNameTaken = errors.New("group name already exists")
|
|
|
|
type UserGroupsList struct {
|
|
Groups []map[string]any `json:"groups"`
|
|
Pagination query.PaginationMeta `json:"pagination,omitempty"`
|
|
}
|
|
|
|
func (s *Service) ListUserGroups(ctx context.Context, params query.ListParams) (UserGroupsList, error) {
|
|
q := strings.TrimSpace(params.Q)
|
|
whereSQL := ""
|
|
args := make([]any, 0, 3)
|
|
if q != "" {
|
|
pattern := "%" + strings.ToLower(q) + "%"
|
|
args = append(args, pattern)
|
|
whereSQL = " WHERE LOWER(name) LIKE $1 OR LOWER(description) LIKE $1"
|
|
}
|
|
|
|
var total int64
|
|
if err := s.db.QueryRow(ctx, "SELECT COUNT(*) FROM user_groups"+whereSQL, args...).Scan(&total); err != nil {
|
|
return UserGroupsList{}, err
|
|
}
|
|
|
|
listSQL := `
|
|
SELECT g.id, g.name, g.description, g.created_at, g.updated_at,
|
|
COALESCE(m.member_count, 0) AS member_count
|
|
FROM user_groups g
|
|
LEFT JOIN (
|
|
SELECT group_id, COUNT(*) AS member_count
|
|
FROM user_group_members
|
|
GROUP BY group_id
|
|
) m ON m.group_id = g.id` + whereSQL + `
|
|
ORDER BY LOWER(g.name)
|
|
LIMIT $` + strconv.Itoa(len(args)+1) + ` OFFSET $` + strconv.Itoa(len(args)+2)
|
|
args = append(args, params.Limit(), params.Offset())
|
|
|
|
rows, err := s.db.Query(ctx, listSQL, args...)
|
|
if err != nil {
|
|
return UserGroupsList{}, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
groups := make([]map[string]any, 0)
|
|
for rows.Next() {
|
|
group, err := scanUserGroupRow(rows)
|
|
if err != nil {
|
|
return UserGroupsList{}, err
|
|
}
|
|
groups = append(groups, group)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return UserGroupsList{}, err
|
|
}
|
|
|
|
return UserGroupsList{
|
|
Groups: groups,
|
|
Pagination: params.Meta(&total),
|
|
}, nil
|
|
}
|
|
|
|
func (s *Service) GetUserGroup(ctx context.Context, groupID string) (map[string]any, error) {
|
|
row := s.db.QueryRow(ctx, `
|
|
SELECT g.id, g.name, g.description, g.created_at, g.updated_at,
|
|
COALESCE(m.member_count, 0) AS member_count
|
|
FROM user_groups g
|
|
LEFT JOIN (
|
|
SELECT group_id, COUNT(*) AS member_count
|
|
FROM user_group_members
|
|
GROUP BY group_id
|
|
) m ON m.group_id = g.id
|
|
WHERE g.id = $1
|
|
`, groupID)
|
|
group, err := scanUserGroupRow(row)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
return group, nil
|
|
}
|
|
|
|
type createUserGroupRequest struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
}
|
|
|
|
type updateUserGroupRequest struct {
|
|
Name *string `json:"name"`
|
|
Description *string `json:"description"`
|
|
}
|
|
|
|
func (s *Service) CreateUserGroup(ctx context.Context, actorSub string, req createUserGroupRequest) (map[string]any, error) {
|
|
name := strings.TrimSpace(req.Name)
|
|
description := strings.TrimSpace(req.Description)
|
|
var id string
|
|
err := s.db.QueryRow(ctx, `
|
|
INSERT INTO user_groups (name, description)
|
|
VALUES ($1, $2)
|
|
RETURNING id
|
|
`, name, description).Scan(&id)
|
|
if err != nil {
|
|
if isUniqueViolation(err) {
|
|
return nil, ErrGroupNameTaken
|
|
}
|
|
return nil, err
|
|
}
|
|
s.logAudit(ctx, actorSub, "create_user_group", map[string]any{
|
|
"group_id": id,
|
|
"name": name,
|
|
})
|
|
return s.GetUserGroup(ctx, id)
|
|
}
|
|
|
|
func (s *Service) UpdateUserGroup(ctx context.Context, actorSub, groupID string, req updateUserGroupRequest) (map[string]any, error) {
|
|
result, err := s.db.Exec(ctx, `
|
|
UPDATE user_groups
|
|
SET name = COALESCE($2, name),
|
|
description = COALESCE($3, description),
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
`, groupID, trimStringPtr(req.Name), trimStringPtr(req.Description))
|
|
if err != nil {
|
|
if isUniqueViolation(err) {
|
|
return nil, ErrGroupNameTaken
|
|
}
|
|
return nil, err
|
|
}
|
|
if result.RowsAffected() == 0 {
|
|
return nil, ErrNotFound
|
|
}
|
|
s.logAudit(ctx, actorSub, "update_user_group", map[string]any{"group_id": groupID})
|
|
return s.GetUserGroup(ctx, groupID)
|
|
}
|
|
|
|
func (s *Service) DeleteUserGroup(ctx context.Context, actorSub, groupID string) error {
|
|
result, err := s.db.Exec(ctx, `DELETE FROM user_groups WHERE id = $1`, groupID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if result.RowsAffected() == 0 {
|
|
return ErrNotFound
|
|
}
|
|
s.logAudit(ctx, actorSub, "delete_user_group", map[string]any{"group_id": groupID})
|
|
return nil
|
|
}
|
|
|
|
type setGroupMembersRequest struct {
|
|
UserIDs []string `json:"user_ids"`
|
|
}
|
|
|
|
func (s *Service) SetGroupMembers(ctx context.Context, actorSub, groupID string, req setGroupMembersRequest) (map[string]any, error) {
|
|
exists, err := s.groupExists(ctx, groupID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !exists {
|
|
return nil, ErrNotFound
|
|
}
|
|
|
|
tx, err := s.db.Begin(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
if _, err := tx.Exec(ctx, `DELETE FROM user_group_members WHERE group_id = $1`, groupID); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, userID := range req.UserIDs {
|
|
userID = strings.TrimSpace(userID)
|
|
if userID == "" {
|
|
continue
|
|
}
|
|
ok, err := s.userExistsTx(ctx, tx, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !ok {
|
|
continue
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO user_group_members (group_id, user_id)
|
|
VALUES ($1, $2)
|
|
ON CONFLICT DO NOTHING
|
|
`, groupID, userID); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
s.logAudit(ctx, actorSub, "set_user_group_members", map[string]any{
|
|
"group_id": groupID,
|
|
"member_count": len(req.UserIDs),
|
|
})
|
|
return s.GetUserGroup(ctx, groupID)
|
|
}
|
|
|
|
func (s *Service) AddUsersToGroup(ctx context.Context, actorSub, groupID string, userIDs []string) error {
|
|
exists, err := s.groupExists(ctx, groupID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !exists {
|
|
return ErrNotFound
|
|
}
|
|
for _, userID := range userIDs {
|
|
userID = strings.TrimSpace(userID)
|
|
if userID == "" {
|
|
continue
|
|
}
|
|
ok, err := s.userExists(ctx, userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !ok {
|
|
continue
|
|
}
|
|
if _, err := s.db.Exec(ctx, `
|
|
INSERT INTO user_group_members (group_id, user_id)
|
|
VALUES ($1, $2)
|
|
ON CONFLICT DO NOTHING
|
|
`, groupID, userID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
s.logAudit(ctx, actorSub, "add_users_to_group", map[string]any{
|
|
"group_id": groupID,
|
|
"count": len(userIDs),
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) RemoveUsersFromGroup(ctx context.Context, actorSub, groupID string, userIDs []string) error {
|
|
exists, err := s.groupExists(ctx, groupID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !exists {
|
|
return ErrNotFound
|
|
}
|
|
for _, userID := range userIDs {
|
|
userID = strings.TrimSpace(userID)
|
|
if userID == "" {
|
|
continue
|
|
}
|
|
if _, err := s.db.Exec(ctx, `
|
|
DELETE FROM user_group_members WHERE group_id = $1 AND user_id = $2
|
|
`, groupID, userID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
s.logAudit(ctx, actorSub, "remove_users_from_group", map[string]any{
|
|
"group_id": groupID,
|
|
"count": len(userIDs),
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) groupExists(ctx context.Context, groupID string) (bool, error) {
|
|
var exists bool
|
|
if err := s.db.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM user_groups WHERE id = $1)`, groupID).Scan(&exists); err != nil {
|
|
return false, err
|
|
}
|
|
return exists, nil
|
|
}
|
|
|
|
func (s *Service) userExistsTx(ctx context.Context, tx pgx.Tx, userID string) (bool, error) {
|
|
var exists bool
|
|
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM users WHERE id = $1)`, userID).Scan(&exists); err != nil {
|
|
return false, err
|
|
}
|
|
return exists, nil
|
|
}
|
|
|
|
type userGroupRowScanner interface {
|
|
Scan(dest ...any) error
|
|
}
|
|
|
|
func scanUserGroupRow(row userGroupRowScanner) (map[string]any, error) {
|
|
var id, name, description string
|
|
var memberCount int64
|
|
var createdAt, updatedAt any
|
|
if err := row.Scan(&id, &name, &description, &createdAt, &updatedAt, &memberCount); err != nil {
|
|
return nil, err
|
|
}
|
|
return map[string]any{
|
|
"id": id,
|
|
"name": name,
|
|
"description": description,
|
|
"member_count": memberCount,
|
|
"created_at": createdAt,
|
|
"updated_at": updatedAt,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Service) attachUsersGroups(ctx context.Context, users []map[string]any) error {
|
|
if len(users) == 0 {
|
|
return nil
|
|
}
|
|
ids := make([]string, 0, len(users))
|
|
byID := make(map[string]map[string]any, len(users))
|
|
for _, user := range users {
|
|
id, _ := user["id"].(string)
|
|
if id == "" {
|
|
continue
|
|
}
|
|
ids = append(ids, id)
|
|
byID[id] = user
|
|
user["groups"] = []map[string]any{}
|
|
}
|
|
if len(ids) == 0 {
|
|
return nil
|
|
}
|
|
|
|
rows, err := s.db.Query(ctx, `
|
|
SELECT ugm.user_id::text, g.id, g.name
|
|
FROM user_group_members ugm
|
|
JOIN user_groups g ON g.id = ugm.group_id
|
|
WHERE ugm.user_id = ANY($1::uuid[])
|
|
ORDER BY LOWER(g.name)
|
|
`, ids)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var userID, groupID, name string
|
|
if err := rows.Scan(&userID, &groupID, &name); err != nil {
|
|
return err
|
|
}
|
|
user, ok := byID[userID]
|
|
if !ok {
|
|
continue
|
|
}
|
|
groups, _ := user["groups"].([]map[string]any)
|
|
user["groups"] = append(groups, map[string]any{
|
|
"id": groupID,
|
|
"name": name,
|
|
})
|
|
}
|
|
return rows.Err()
|
|
}
|
|
|
|
func isUniqueViolation(err error) bool {
|
|
var pgErr *pgconn.PgError
|
|
return errors.As(err, &pgErr) && pgErr.Code == "23505"
|
|
}
|