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
65 changes: 65 additions & 0 deletions entity-service/internal/domain/entity.go
Original file line number Diff line number Diff line change
Expand Up @@ -697,6 +697,71 @@ type SearchSecurityReportAnalysisFilters struct {
CreatedByMe bool `json:"createdByMe"`
}

// EngagementTypeKey classifies the type of an engagement case.
type EngagementTypeKey string

const (
EngagementTypeMigration EngagementTypeKey = "migration"
EngagementTypeConsultancy EngagementTypeKey = "consultancy"
EngagementTypeNewFeatureImprovement EngagementTypeKey = "new_feature_improvement"
EngagementTypeFollowUp EngagementTypeKey = "follow_up"
EngagementTypeOnboarding EngagementTypeKey = "onboarding"
)

// SearchEngagementsFilters holds optional filter criteria for an engagement search.
// This endpoint is only supported for the ServiceNow data source.
type SearchEngagementsFilters struct {
ProjectIDs []string `json:"projectIds"`
CaseTypes []string `json:"caseTypes"`
SearchQuery string `json:"searchQuery"`
TypeKeys []EngagementTypeKey `json:"typeKeys"`
ClosedStartDate *time.Time `json:"closedStartDate"`
ClosedEndDate *time.Time `json:"closedEndDate"`
StartCreatedDate *time.Time `json:"startCreatedDate"`
EndCreatedDate *time.Time `json:"endCreatedDate"`
StartUpdatedDate *time.Time `json:"startUpdatedDate"`
EndUpdatedDate *time.Time `json:"endUpdatedDate"`
DeploymentIDs []string `json:"deploymentIds"`
CreatedBy []string `json:"createdBy"`
CreatedByMe bool `json:"createdByMe"`
}

// SearchEngagementsRequest is the input for POST /engagements/search.
type SearchEngagementsRequest struct {
Filters SearchEngagementsFilters `json:"filters"`
SortBy CaseSort `json:"sortBy"`
Pagination Pagination `json:"pagination"`
}

// EngagementView is the enriched representation of an engagement returned in search results.
type EngagementView struct {
ID string `json:"id"`
InternalID string `json:"internalId"`
Number string `json:"number"`
CreatedOn string `json:"createdOn"`
CreatedBy string `json:"createdBy"`
Title *string `json:"title"`
Description *string `json:"description"`
State string `json:"state"`
WorkState *string `json:"workState"`
Type *string `json:"type"`
Product *EntityRef `json:"product"`
Project EntityRef `json:"project"`
Deployment EntityRef `json:"deployment"`
DeployedProduct EntityRef `json:"deployedProduct"`
AssignedEngineer *AssignedEngineerRef `json:"assignedEngineer"`
ParentCase *EntityRef `json:"parentCase"`
RelatedCase *EntityRef `json:"relatedCase"`
}

// SearchEngagementsResponse is the paginated result of an engagement search.
type SearchEngagementsResponse struct {
Engagements []EngagementView `json:"engagements"`
TotalRecords int `json:"totalRecords"`
Offset int `json:"offset"`
Limit int `json:"limit"`
}

// SearchSecurityReportAnalysisRequest is the input for POST /security-report-analysis/search.
type SearchSecurityReportAnalysisRequest struct {
Filters SearchSecurityReportAnalysisFilters `json:"filters"`
Expand Down
15 changes: 15 additions & 0 deletions entity-service/internal/handler/case_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -204,3 +204,18 @@ func (h *CaseHandler) SearchSecurityReportAnalysis(w http.ResponseWriter, r *htt
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}

// SearchEngagements handles POST /engagements/search.
func (h *CaseHandler) SearchEngagements(w http.ResponseWriter, r *http.Request) {
var req domain.SearchEngagementsRequest
if !decodeRequest(w, r, &req) {
return
}
resp, err := h.svc.SearchEngagements(r.Context(), req)
if err != nil {
writeServiceError(w, r, err)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}
1 change: 1 addition & 0 deletions entity-service/internal/server/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ func NewRouter(db *pgxpool.Pool, cfg *config.Config) http.Handler {
mux.HandleFunc("GET /cases/{case_id}/attachments/{attachment_id}/content", caseHandler.GetCaseAttachmentContent)
mux.HandleFunc("POST /service-requests/search", caseHandler.SearchServiceRequests)
mux.HandleFunc("POST /security-report-analyses/search", caseHandler.SearchSecurityReportAnalysis)
mux.HandleFunc("POST /engagements/search", caseHandler.SearchEngagements)

return middleware.CorrelationID(
middleware.Recovery(
Expand Down
4 changes: 4 additions & 0 deletions entity-service/internal/service/case_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -380,3 +380,7 @@ func (s *caseService) SearchServiceRequests(_ context.Context, _ domain.SearchSe
func (s *caseService) SearchSecurityReportAnalysis(_ context.Context, _ domain.SearchSecurityReportAnalysisRequest) (domain.SearchSecurityReportAnalysisResponse, error) {
return domain.SearchSecurityReportAnalysisResponse{}, &apierror.ServiceUnavailableError{Msg: "security report analysis is only supported for the ServiceNow data source"}
}

func (s *caseService) SearchEngagements(_ context.Context, _ domain.SearchEngagementsRequest) (domain.SearchEngagementsResponse, error) {
return domain.SearchEngagementsResponse{}, &apierror.ServiceUnavailableError{Msg: "engagements are only supported for the ServiceNow data source"}
}
3 changes: 3 additions & 0 deletions entity-service/internal/service/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,4 +127,7 @@ type CaseService interface {
// SearchSecurityReportAnalysis returns a paginated list of security report analyses.
// Only supported for the ServiceNow data source; returns ServiceUnavailableError otherwise.
SearchSecurityReportAnalysis(ctx context.Context, req domain.SearchSecurityReportAnalysisRequest) (domain.SearchSecurityReportAnalysisResponse, error)
// SearchEngagements returns a paginated list of engagements.
// Only supported for the ServiceNow data source; returns ServiceUnavailableError otherwise.
SearchEngagements(ctx context.Context, req domain.SearchEngagementsRequest) (domain.SearchEngagementsResponse, error)
}
202 changes: 186 additions & 16 deletions entity-service/internal/service/sn_case_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,22 +157,23 @@ var snSortFieldMap = map[domain.CaseSortField]string{
}

type snCaseFilters struct {
CaseTypes []string `json:"caseTypes"`
SearchQuery string `json:"searchQuery,omitempty"`
ProjectIDs []string `json:"projectIds,omitempty"`
DeploymentIDs []string `json:"deploymentIds,omitempty"`
DeployedProductIDs []string `json:"deployedProductIds,omitempty"`
StateKeys []int `json:"stateKeys,omitempty"`
SeverityKeys []int `json:"severityKeys,omitempty"`
IssueTypeKeys []int `json:"issueTypeKeys,omitempty"`
ClosedStartDate string `json:"closedStartDate,omitempty"`
ClosedEndDate string `json:"closedEndDate,omitempty"`
StartCreatedDate string `json:"startCreatedDate,omitempty"`
EndCreatedDate string `json:"endCreatedDate,omitempty"`
StartUpdatedDate string `json:"startUpdatedDate,omitempty"`
EndUpdatedDate string `json:"endUpdatedDate,omitempty"`
CreatedBy []string `json:"createdBy,omitempty"`
CreatedByMe bool `json:"createdByMe,omitempty"`
CaseTypes []string `json:"caseTypes"`
SearchQuery string `json:"searchQuery,omitempty"`
ProjectIDs []string `json:"projectIds,omitempty"`
DeploymentIDs []string `json:"deploymentIds,omitempty"`
DeployedProductIDs []string `json:"deployedProductIds,omitempty"`
StateKeys []int `json:"stateKeys,omitempty"`
SeverityKeys []int `json:"severityKeys,omitempty"`
IssueTypeKeys []int `json:"issueTypeKeys,omitempty"`
EngagementTypeKeys []int `json:"engagementTypeKeys,omitempty"`
ClosedStartDate string `json:"closedStartDate,omitempty"`
ClosedEndDate string `json:"closedEndDate,omitempty"`
StartCreatedDate string `json:"startCreatedDate,omitempty"`
EndCreatedDate string `json:"endCreatedDate,omitempty"`
StartUpdatedDate string `json:"startUpdatedDate,omitempty"`
EndUpdatedDate string `json:"endUpdatedDate,omitempty"`
CreatedBy []string `json:"createdBy,omitempty"`
CreatedByMe bool `json:"createdByMe,omitempty"`
}

// snStateIDMap maps domain CaseState enums to SN numeric state IDs.
Expand Down Expand Up @@ -1432,3 +1433,172 @@ func snServiceRequestStateLabel(state *snCaseState) string {
}
return state.Label
}

// snEngagementTypeIDMap maps domain EngagementTypeKey enums to SN numeric engagement type IDs.
var snEngagementTypeIDMap = map[domain.EngagementTypeKey]int{
domain.EngagementTypeMigration: 1,
domain.EngagementTypeConsultancy: 2,
domain.EngagementTypeNewFeatureImprovement: 3,
domain.EngagementTypeFollowUp: 4,
domain.EngagementTypeOnboarding: 5,
}

func domainEngagementTypesToSNIDs(keys []domain.EngagementTypeKey) []int {
ids := make([]int, 0, len(keys))
for _, k := range keys {
if id, ok := snEngagementTypeIDMap[k]; ok {
ids = append(ids, id)
}
}
return ids
}

type snEngagementCase struct {
ID string `json:"id"`
InternalID string `json:"internalId"`
Number string `json:"number"`
Title *string `json:"title"`
Description *string `json:"description"`
CreatedOn string `json:"createdOn"`
CreatedBy string `json:"createdBy"`
State *snCaseState `json:"state"`
WorkState *snCaseLabel `json:"workState"`
EngagementType *snCaseLabel `json:"engagementType"`
Project snCaseEntityRef `json:"project"`
Deployment snCaseEntityRef `json:"deployment"`
DeployedProduct snCaseDeployedProduct `json:"deployedProduct"`
Product *snCaseEntityRef `json:"product"`
AssignedEngineer *snAssignedEngineerRef `json:"assignedEngineer"`
ParentCase *snCaseRef `json:"parentCase"`
RelatedCase *snCaseRef `json:"relatedCase"`
}

type snEngagementsResponse struct {
Cases []snEngagementCase `json:"cases"`
TotalRecords int `json:"totalRecords"`
Offset int `json:"offset"`
Limit int `json:"limit"`
}

// SearchEngagements implements CaseService by calling the Choreo POST /cases/search
// endpoint with caseTypes filtered to ["engagement"].
func (s *snCaseService) SearchEngagements(ctx context.Context, req domain.SearchEngagementsRequest) (domain.SearchEngagementsResponse, error) {
if err := normalizePagination(&req.Pagination); err != nil {
return domain.SearchEngagementsResponse{}, err
}
if err := validateSearchQuery(req.Filters.SearchQuery); err != nil {
return domain.SearchEngagementsResponse{}, err
}

if req.Filters.ClosedEndDate != nil && req.Filters.ClosedStartDate != nil &&
req.Filters.ClosedEndDate.Before(*req.Filters.ClosedStartDate) {
return domain.SearchEngagementsResponse{}, &apierror.ValidationError{Msg: "closedEndDate must not be before closedStartDate"}
}
if req.Filters.EndCreatedDate != nil && req.Filters.StartCreatedDate != nil &&
req.Filters.EndCreatedDate.Before(*req.Filters.StartCreatedDate) {
return domain.SearchEngagementsResponse{}, &apierror.ValidationError{Msg: "endCreatedDate must not be before startCreatedDate"}
}
if req.Filters.EndUpdatedDate != nil && req.Filters.StartUpdatedDate != nil &&
req.Filters.EndUpdatedDate.Before(*req.Filters.StartUpdatedDate) {
return domain.SearchEngagementsResponse{}, &apierror.ValidationError{Msg: "endUpdatedDate must not be before startUpdatedDate"}
}

for _, k := range req.Filters.TypeKeys {
if _, ok := snEngagementTypeIDMap[k]; !ok {
return domain.SearchEngagementsResponse{}, &apierror.ValidationError{Msg: "engagementTypeKeys contains unknown value: " + string(k)}
}
}

token := middleware.UserIDTokenFromContext(ctx)
if token == "" {
return domain.SearchEngagementsResponse{}, &apierror.UnauthorizedError{Msg: "x-user-id-token header is required"}
}

var snSortBy *snCaseSort
if req.SortBy.Field != "" {
snField, ok := snSortFieldMap[req.SortBy.Field]
if !ok {
return domain.SearchEngagementsResponse{}, &apierror.ValidationError{Msg: "sortBy.field " + string(req.SortBy.Field) + " is not supported by ServiceNow"}
}
order := string(req.SortBy.Order)
if order == "" {
order = "desc"
}
snSortBy = &snCaseSort{Field: snField, Order: order}
}

payload := snCaseSearchPayload{
Filters: snCaseFilters{
CaseTypes: []string{"engagement"},
SearchQuery: req.Filters.SearchQuery,
ProjectIDs: uuidsToSysids(req.Filters.ProjectIDs),
DeploymentIDs: uuidsToSysids(req.Filters.DeploymentIDs),
EngagementTypeKeys: domainEngagementTypesToSNIDs(req.Filters.TypeKeys),
ClosedStartDate: formatSNDate(req.Filters.ClosedStartDate),
ClosedEndDate: formatSNDate(req.Filters.ClosedEndDate),
StartCreatedDate: formatSNDate(req.Filters.StartCreatedDate),
EndCreatedDate: formatSNDate(req.Filters.EndCreatedDate),
StartUpdatedDate: formatSNDate(req.Filters.StartUpdatedDate),
EndUpdatedDate: formatSNDate(req.Filters.EndUpdatedDate),
CreatedBy: req.Filters.CreatedBy,
CreatedByMe: req.Filters.CreatedByMe,
},
SortBy: snSortBy,
Pagination: snProjectPagination{Limit: req.Pagination.Limit, Offset: req.Pagination.Offset},
}

raw, err := s.client.Post(ctx, "/cases/search", token, payload)
if err != nil {
return domain.SearchEngagementsResponse{}, err
}

var snResp snEngagementsResponse
if err := json.Unmarshal(raw, &snResp); err != nil {
return domain.SearchEngagementsResponse{}, fmt.Errorf("sn engagements: parse response: %w", err)
}

views := make([]domain.EngagementView, 0, len(snResp.Cases))
for _, c := range snResp.Cases {
view := domain.EngagementView{
ID: sysidToUUID(c.ID),
InternalID: c.InternalID,
Number: c.Number,
CreatedOn: c.CreatedOn,
CreatedBy: c.CreatedBy,
Title: c.Title,
Description: c.Description,
State: snCaseStateLabel(c.State),
WorkState: snCaseLabelPtr(c.WorkState),
Type: snCaseLabelPtr(c.EngagementType),
Project: domain.EntityRef{ID: sysidToUUID(c.Project.ID), Name: c.Project.Name},
Deployment: domain.EntityRef{ID: sysidToUUID(c.Deployment.ID), Name: c.Deployment.Name},
DeployedProduct: domain.EntityRef{
ID: sysidToUUID(c.DeployedProduct.ID),
Name: strings.TrimSpace(c.DeployedProduct.Name + " " + c.DeployedProduct.Version),
},
}
if c.Product != nil {
ref := domain.EntityRef{ID: sysidToUUID(c.Product.ID), Name: c.Product.Name}
view.Product = &ref
}
if c.AssignedEngineer != nil {
view.AssignedEngineer = &domain.AssignedEngineerRef{ID: sysidToUUID(c.AssignedEngineer.ID), Name: c.AssignedEngineer.Name, Email: c.AssignedEngineer.Email}
}
if c.ParentCase != nil {
ref := domain.EntityRef{ID: sysidToUUID(c.ParentCase.ID), Name: c.ParentCase.Number}
view.ParentCase = &ref
}
if c.RelatedCase != nil {
ref := domain.EntityRef{ID: sysidToUUID(c.RelatedCase.ID), Name: c.RelatedCase.Number}
view.RelatedCase = &ref
}
views = append(views, view)
}

return domain.SearchEngagementsResponse{
Engagements: views,
TotalRecords: snResp.TotalRecords,
Offset: req.Pagination.Offset,
Limit: req.Pagination.Limit,
}, nil
}
Loading