Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 73 additions & 5 deletions entity-service/internal/domain/entity.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,21 +50,68 @@ type User struct {
UpdatedOn time.Time `json:"updatedOn"`
}

// UserRole represents a ServiceNow role that can be assigned to a user.
type UserRole string

const (
UserRoleInternal UserRole = "internal"
UserRoleAgent UserRole = "agent"
UserRoleAdmin UserRole = "admin"
UserRoleCommenter UserRole = "commenter"
UserRoleExternal UserRole = "external"
UserRoleCustomer UserRole = "customer"
UserRoleCustomerAdmin UserRole = "customer_admin"
UserRolePartner UserRole = "partner"
UserRolePartnerAdmin UserRole = "partner_admin"
)

// UserSortField enumerates the columns by which user search results may be ordered.
type UserSortField string

const (
UserSortFieldName UserSortField = "name"
UserSortFieldCreatedOn UserSortField = "createdOn"
UserSortFieldUpdatedOn UserSortField = "updatedOn"
)

// UserSortOrder is the direction of a user search sort.
type UserSortOrder string

const (
UserSortOrderAsc UserSortOrder = "asc"
UserSortOrderDesc UserSortOrder = "desc"
)

// Pagination controls which page of results is returned.
// Limit defaults to 20 and is capped at 100 by the service layer.
// Limit defaults to 10 and is capped at 50 by the service layer.
type Pagination struct {
Limit int `json:"limit"`
Offset int `json:"offset"`
}

// SearchUsersFilters holds the optional filter criteria for a user search.
type SearchUsersFilters struct {
SearchQuery string `json:"searchQuery"`
Roles []UserRole `json:"roles"`
UserNames []string `json:"userNames"`
Emails []string `json:"emails"`
Active *bool `json:"active"`
}

// UserSortBy specifies the sort field and direction for a user search.
type UserSortBy struct {
Field UserSortField `json:"field"`
Order UserSortOrder `json:"order"`
}

// SearchUsersRequest is the input for a user search operation.
// SearchQuery is matched case-insensitively against username and email.
type SearchUsersRequest struct {
Pagination Pagination `json:"pagination"`
SearchQuery string `json:"searchQuery"`
Pagination Pagination `json:"pagination"`
Filters SearchUsersFilters `json:"filters"`
Comment thread
cloby99 marked this conversation as resolved.
SortBy UserSortBy `json:"sortBy"`
}

// SearchUsersResponse is the paginated result of a user search.
// SearchUsersResponse is the paginated result of a postgres user search.
// HasMore is true when additional pages are available beyond the current offset.
type SearchUsersResponse struct {
Users []User `json:"users"`
Expand All @@ -74,6 +121,27 @@ type SearchUsersResponse struct {
HasMore bool `json:"hasMore"`
}

// SNUser is the user view returned by the ServiceNow data source.
type SNUser struct {
ID string `json:"id"`
UserName string `json:"userName"`
Name string `json:"name"`
Email string `json:"email"`
TimeZone *string `json:"timeZone"`
Active bool `json:"active"`
CreatedOn string `json:"createdOn"`
UpdatedOn string `json:"updatedOn"`
Roles []string `json:"roles"`
}

// SearchSNUsersResponse is the paginated result of a ServiceNow user search.
type SearchSNUsersResponse struct {
Users []SNUser `json:"users"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}

// AccountTier represents the subscription tier of an account.
type AccountTier string

Expand Down
27 changes: 26 additions & 1 deletion entity-service/internal/handler/user_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ func NewUserHandler(svc service.UserService) *UserHandler {
return &UserHandler{svc: svc}
}

// SearchUsers handles POST /users/search.
// SearchUsers handles POST /users/search for the postgres data source.
func (h *UserHandler) SearchUsers(w http.ResponseWriter, r *http.Request) {
var req domain.SearchUsersRequest
if !decodeRequest(w, r, &req) {
Expand All @@ -53,3 +53,28 @@ func (h *UserHandler) SearchUsers(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}

// SNUserHandler handles HTTP requests for the user resource backed by ServiceNow.
type SNUserHandler struct {
svc service.SNUserService
}

// NewSNUserHandler constructs an SNUserHandler with the given service.
func NewSNUserHandler(svc service.SNUserService) *SNUserHandler {
return &SNUserHandler{svc: svc}
}

// SearchUsers handles POST /users/search for the ServiceNow data source.
func (h *SNUserHandler) SearchUsers(w http.ResponseWriter, r *http.Request) {
var req domain.SearchUsersRequest
if !decodeRequest(w, r, &req) {
return
}
resp, err := h.svc.SearchUsers(r.Context(), req)
if err != nil {
writeServiceError(w, r, err)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}
24 changes: 22 additions & 2 deletions entity-service/internal/repository/user_repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,8 @@ func (r *userRepo) SearchUsers(ctx context.Context, req domain.SearchUsersReques

where := "WHERE 1=1"

if req.SearchQuery != "" {
escaped := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(req.SearchQuery)
if req.Filters.SearchQuery != "" {
escaped := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(req.Filters.SearchQuery)
pattern := "%" + escaped + "%"
// Both branches reference the same positional parameter — PostgreSQL allows $N to appear multiple times.
where += fmt.Sprintf(
Expand All @@ -93,6 +93,26 @@ func (r *userRepo) SearchUsers(ctx context.Context, req domain.SearchUsersReques
argIdx++
}

if len(req.Filters.UserNames) > 0 {
placeholders := make([]string, len(req.Filters.UserNames))
for i, un := range req.Filters.UserNames {
placeholders[i] = fmt.Sprintf("$%d", argIdx)
filterArgs = append(filterArgs, un)
argIdx++
}
where += " AND user_name = ANY(ARRAY[" + strings.Join(placeholders, ",") + "])"
}

if len(req.Filters.Emails) > 0 {
placeholders := make([]string, len(req.Filters.Emails))
for i, em := range req.Filters.Emails {
placeholders[i] = fmt.Sprintf("$%d", argIdx)
filterArgs = append(filterArgs, em)
argIdx++
}
where += " AND email = ANY(ARRAY[" + strings.Join(placeholders, ",") + "])"
}

countQuery := "SELECT COUNT(*) FROM users " + where

dataQuery := fmt.Sprintf(
Expand Down
11 changes: 10 additions & 1 deletion entity-service/internal/server/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,10 +122,19 @@ func NewRouter(db *pgxpool.Pool, cfg *config.Config) http.Handler {
productVulnerabilityHandler = handler.NewProductVulnerabilityHandler(service.NewServiceNowProductVulnerabilityService(serviceNowIntegrationServiceClient))
}

var snUserHandler *handler.SNUserHandler
if cfg.DataSource == config.DataSourceServiceNow {
snUserHandler = handler.NewSNUserHandler(service.NewServiceNowUserService(serviceNowIntegrationServiceClient))
}

mux := http.NewServeMux()

mux.HandleFunc("GET /health", handler.HealthCheck)
mux.HandleFunc("POST /users/search", userHandler.SearchUsers)
if snUserHandler != nil {
mux.HandleFunc("POST /users/search", snUserHandler.SearchUsers)
} else {
mux.HandleFunc("POST /users/search", userHandler.SearchUsers)
}
mux.HandleFunc("GET /accounts/{id}", accountHandler.GetAccount)
mux.HandleFunc("POST /accounts/search", accountHandler.SearchAccounts)
mux.HandleFunc("GET /projects/{id}", projectHandler.GetProject)
Expand Down
10 changes: 9 additions & 1 deletion entity-service/internal/service/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,19 @@ import (
// making it straightforward to substitute a test double in unit tests.
type UserService interface {
// SearchUsers returns a paginated list of users that match the filters in
// req. A ValidationError is returned for invalid input (e.g. limit > 100);
// req. A ValidationError is returned for invalid input (e.g. limit > 50);
// any other error indicates an infrastructure failure.
SearchUsers(ctx context.Context, req domain.SearchUsersRequest) (domain.SearchUsersResponse, error)
}

// SNUserService defines the user search operation backed by the ServiceNow data source.
type SNUserService interface {
// SearchUsers returns a paginated list of ServiceNow users that match the
// filters in req. A ValidationError is returned for invalid input; any other
// error indicates an infrastructure failure.
SearchUsers(ctx context.Context, req domain.SearchUsersRequest) (domain.SearchSNUsersResponse, error)
}
Comment thread
cloby99 marked this conversation as resolved.

// AccountService defines the operations available on the account entity.
type AccountService interface {
// SearchAccounts returns a paginated list of accounts that match the filters
Expand Down
Loading