package postgres import ( "context" "database/sql" "encoding/json" "errors" "fmt" "switchboard-core/models" "switchboard-core/store" ) // ── GroupStore ────────────────────────────── type GroupStore struct{} func NewGroupStore() *GroupStore { return &GroupStore{} } func (s *GroupStore) Create(ctx context.Context, g *models.Group) error { if g.Source == "" { g.Source = "manual" } if g.Permissions == nil { g.Permissions = []string{} } permsJSON, err := json.Marshal(g.Permissions) if err != nil { return fmt.Errorf("marshal permissions: %w", err) } return DB.QueryRowContext(ctx, ` INSERT INTO groups (name, description, scope, team_id, created_by, source, permissions) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id, created_at, updated_at`, g.Name, g.Description, g.Scope, models.NullString(g.TeamID), models.NullString(g.CreatedBy), g.Source, permsJSON, ).Scan(&g.ID, &g.CreatedAt, &g.UpdatedAt) } func (s *GroupStore) GetByID(ctx context.Context, id string) (*models.Group, error) { row := DB.QueryRowContext(ctx, ` SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by, g.created_at, g.updated_at, (SELECT COUNT(*) FROM group_members WHERE group_id = g.id) AS member_count, COALESCE(g.source, 'manual'), COALESCE(g.permissions, '[]'::jsonb), g.token_budget_daily, g.token_budget_monthly, g.allowed_models FROM groups g WHERE g.id = $1`, id) return scanGroup(row) } // Update applies a patch to a group. // The Everyone group (source=system) allows permission/budget edits but not // name, description, or structural changes — those fields are silently ignored. func (s *GroupStore) Update(ctx context.Context, id string, patch models.GroupPatch) error { var source string if err := DB.QueryRowContext(ctx, "SELECT source FROM groups WHERE id = $1", id).Scan(&source); err != nil { return err } b := NewUpdate("groups") if source != "system" { if patch.Name != nil { b.Set("name", *patch.Name) } if patch.Description != nil { b.Set("description", *patch.Description) } } if patch.Permissions != nil { permsJSON, err := json.Marshal(*patch.Permissions) if err != nil { return fmt.Errorf("marshal permissions: %w", err) } b.Set("permissions", permsJSON) } if patch.ClearBudgetDaily { b.SetNull("token_budget_daily") } else if patch.TokenBudgetDaily != nil { b.Set("token_budget_daily", *patch.TokenBudgetDaily) } if patch.ClearBudgetMonthly { b.SetNull("token_budget_monthly") } else if patch.TokenBudgetMonthly != nil { b.Set("token_budget_monthly", *patch.TokenBudgetMonthly) } if patch.ClearAllowedModels { b.SetNull("allowed_models") } else if patch.AllowedModels != nil { modelsJSON, err := json.Marshal(*patch.AllowedModels) if err != nil { return fmt.Errorf("marshal allowed_models: %w", err) } b.Set("allowed_models", modelsJSON) } if !b.HasSets() { return nil } b.Where("id", id) res, err := b.Exec(DB) if err != nil { return err } if n, _ := res.RowsAffected(); n == 0 { return sql.ErrNoRows } return nil } // Delete removes a group. Returns an error if source=system. func (s *GroupStore) Delete(ctx context.Context, id string) error { var source string if err := DB.QueryRowContext(ctx, "SELECT source FROM groups WHERE id = $1", id).Scan(&source); err != nil { if errors.Is(err, sql.ErrNoRows) { return sql.ErrNoRows } return err } if source == "system" { return store.ErrSystemGroup } res, err := DB.ExecContext(ctx, "DELETE FROM groups WHERE id = $1", id) if err != nil { return err } if n, _ := res.RowsAffected(); n == 0 { return sql.ErrNoRows } return nil } // ── Scoped Listing ────────────────────────── const groupSelectCols = ` g.id, g.name, g.description, g.scope, g.team_id, g.created_by, g.created_at, g.updated_at, (SELECT COUNT(*) FROM group_members WHERE group_id = g.id) AS member_count, COALESCE(g.source, 'manual'), COALESCE(g.permissions, '[]'::jsonb), g.token_budget_daily, g.token_budget_monthly, g.allowed_models` func (s *GroupStore) ListAll(ctx context.Context) ([]models.Group, error) { return queryGroups(ctx, `SELECT`+groupSelectCols+` FROM groups g ORDER BY g.scope, g.name`) } func (s *GroupStore) ListForTeam(ctx context.Context, teamID string) ([]models.Group, error) { return queryGroups(ctx, `SELECT`+groupSelectCols+` FROM groups g WHERE g.scope = 'team' AND g.team_id = $1 ORDER BY g.name`, teamID) } func (s *GroupStore) ListForUser(ctx context.Context, userID string) ([]models.Group, error) { return queryGroups(ctx, `SELECT`+groupSelectCols+` FROM groups g WHERE g.id IN (SELECT group_id FROM group_members WHERE user_id = $1) ORDER BY g.scope, g.name`, userID) } // ── Members ───────────────────────────────── func (s *GroupStore) AddMember(ctx context.Context, groupID, userID, addedBy string) error { _, err := DB.ExecContext(ctx, ` INSERT INTO group_members (group_id, user_id, added_by) VALUES ($1, $2, $3) ON CONFLICT (group_id, user_id) DO NOTHING`, groupID, userID, addedBy) return err } func (s *GroupStore) RemoveMember(ctx context.Context, groupID, userID string) error { res, err := DB.ExecContext(ctx, "DELETE FROM group_members WHERE group_id = $1 AND user_id = $2", groupID, userID) if err != nil { return err } if n, _ := res.RowsAffected(); n == 0 { return sql.ErrNoRows } return nil } func (s *GroupStore) ListMembers(ctx context.Context, groupID string) ([]models.GroupMember, error) { rows, err := DB.QueryContext(ctx, ` SELECT gm.id, gm.group_id, gm.user_id, gm.added_by, gm.added_at, u.username, u.email, COALESCE(u.display_name, '') FROM group_members gm JOIN users u ON u.id = gm.user_id WHERE gm.group_id = $1 ORDER BY u.username`, groupID) if err != nil { return nil, err } defer rows.Close() var result []models.GroupMember for rows.Next() { var m models.GroupMember if err := rows.Scan(&m.ID, &m.GroupID, &m.UserID, &m.AddedBy, &m.AddedAt, &m.Username, &m.Email, &m.DisplayName); err != nil { return nil, err } result = append(result, m) } return result, rows.Err() } func (s *GroupStore) IsMember(ctx context.Context, groupID, userID string) (bool, error) { var exists bool err := DB.QueryRowContext(ctx, ` SELECT EXISTS(SELECT 1 FROM group_members WHERE group_id = $1 AND user_id = $2)`, groupID, userID).Scan(&exists) return exists, err } func (s *GroupStore) GetUserGroupIDs(ctx context.Context, userID string) ([]string, error) { rows, err := DB.QueryContext(ctx, "SELECT group_id FROM group_members WHERE user_id = $1", userID) if err != nil { return nil, err } defer rows.Close() var ids []string for rows.Next() { var id string if err := rows.Scan(&id); err != nil { return nil, err } ids = append(ids, id) } return ids, rows.Err() } // ── Scanners ──────────────────────────────── type groupScanner interface { Scan(dest ...any) error } func scanGroup(row groupScanner) (*models.Group, error) { var g models.Group var teamID sql.NullString var createdBy sql.NullString var permsJSON []byte var budgetDaily, budgetMonthly sql.NullInt64 var allowedJSON []byte if err := row.Scan( &g.ID, &g.Name, &g.Description, &g.Scope, &teamID, &createdBy, &g.CreatedAt, &g.UpdatedAt, &g.MemberCount, &g.Source, &permsJSON, &budgetDaily, &budgetMonthly, &allowedJSON, ); err != nil { return nil, err } g.TeamID = NullableStringPtr(teamID) g.CreatedBy = NullableStringPtr(createdBy) g.Permissions = unmarshalStringSlice(permsJSON) if budgetDaily.Valid { g.TokenBudgetDaily = &budgetDaily.Int64 } if budgetMonthly.Valid { g.TokenBudgetMonthly = &budgetMonthly.Int64 } if len(allowedJSON) > 0 && string(allowedJSON) != "null" { g.AllowedModels = unmarshalStringSlice(allowedJSON) } return &g, nil } func queryGroups(ctx context.Context, q string, args ...interface{}) ([]models.Group, error) { rows, err := DB.QueryContext(ctx, q, args...) if err != nil { return nil, fmt.Errorf("queryGroups: %w", err) } defer rows.Close() var result []models.Group for rows.Next() { var g models.Group var teamID sql.NullString var createdBy sql.NullString var permsJSON []byte var budgetDaily, budgetMonthly sql.NullInt64 var allowedJSON []byte if err := rows.Scan( &g.ID, &g.Name, &g.Description, &g.Scope, &teamID, &createdBy, &g.CreatedAt, &g.UpdatedAt, &g.MemberCount, &g.Source, &permsJSON, &budgetDaily, &budgetMonthly, &allowedJSON, ); err != nil { return nil, err } g.TeamID = NullableStringPtr(teamID) g.CreatedBy = NullableStringPtr(createdBy) g.Permissions = unmarshalStringSlice(permsJSON) if budgetDaily.Valid { g.TokenBudgetDaily = &budgetDaily.Int64 } if budgetMonthly.Valid { g.TokenBudgetMonthly = &budgetMonthly.Int64 } if len(allowedJSON) > 0 && string(allowedJSON) != "null" { g.AllowedModels = unmarshalStringSlice(allowedJSON) } result = append(result, g) } return result, rows.Err() } func unmarshalStringSlice(data []byte) []string { if len(data) == 0 { return []string{} } var s []string if err := json.Unmarshal(data, &s); err != nil { return []string{} } return s }