diff --git a/apps/csm-portal/backend/cmd/server/main.go b/apps/csm-portal/backend/cmd/server/main.go index 24d7e7842f..544b472074 100644 --- a/apps/csm-portal/backend/cmd/server/main.go +++ b/apps/csm-portal/backend/cmd/server/main.go @@ -94,6 +94,8 @@ func main() { mux.HandleFunc("GET /cases/{case_id}/attachments/{attachment_id}/content", caseHandler.GetCaseAttachmentContent) mux.HandleFunc("POST /cases/search", caseHandler.SearchCases) mux.HandleFunc("POST /service-requests/search", caseHandler.SearchServiceRequests) + mux.HandleFunc("POST /security-report-analyses/search", caseHandler.SearchSecurityReportAnalyses) + mux.HandleFunc("POST /engagements/search", caseHandler.SearchEngagements) mux.HandleFunc("GET /updates/product-update-levels", updatesHandler.GetProductUpdateLevels) mux.HandleFunc("POST /updates/levels/search", updatesHandler.SearchUpdatesBetweenUpdateLevels) mux.HandleFunc("GET /users/me", usersHandler.GetMe) diff --git a/apps/csm-portal/backend/internal/entity/entity.go b/apps/csm-portal/backend/internal/entity/entity.go index 939055052f..526baf9436 100644 --- a/apps/csm-portal/backend/internal/entity/entity.go +++ b/apps/csm-portal/backend/internal/entity/entity.go @@ -41,6 +41,18 @@ func (c *Client) SearchServiceRequests(ctx context.Context, body []byte) ([]byte return c.do(ctx, http.MethodPost, "/service-requests/search", body) } +// SearchSecurityReportAnalyses calls POST /security-report-analyses/search on the entity service. +// Response is returned as raw JSON; typed response structs are deferred. +func (c *Client) SearchSecurityReportAnalyses(ctx context.Context, body []byte) ([]byte, error) { + return c.do(ctx, http.MethodPost, "/security-report-analyses/search", body) +} + +// SearchEngagements calls POST /engagements/search on the entity service. +// Response is returned as raw JSON; typed response structs are deferred. +func (c *Client) SearchEngagements(ctx context.Context, body []byte) ([]byte, error) { + return c.do(ctx, http.MethodPost, "/engagements/search", body) +} + // GetCase calls GET /cases/{id} on the entity service. // Response is returned as raw JSON; typed response structs are deferred. func (c *Client) GetCase(ctx context.Context, caseID string) ([]byte, error) { diff --git a/apps/csm-portal/backend/internal/handler/cases.go b/apps/csm-portal/backend/internal/handler/cases.go index b0a1be0111..66f2d0260c 100644 --- a/apps/csm-portal/backend/internal/handler/cases.go +++ b/apps/csm-portal/backend/internal/handler/cases.go @@ -49,6 +49,8 @@ type entityCaseClient interface { SearchCaseComments(ctx context.Context, caseID string, body []byte) ([]byte, error) SearchCases(ctx context.Context, body []byte) ([]byte, error) SearchServiceRequests(ctx context.Context, body []byte) ([]byte, error) + SearchSecurityReportAnalyses(ctx context.Context, body []byte) ([]byte, error) + SearchEngagements(ctx context.Context, body []byte) ([]byte, error) GetCase(ctx context.Context, caseID string) ([]byte, error) CreateCaseAttachment(ctx context.Context, caseID string, body []byte) ([]byte, error) SearchCaseAttachments(ctx context.Context, caseID string, body []byte) ([]byte, error) @@ -327,6 +329,74 @@ func (h *CaseHandler) SearchServiceRequests(w http.ResponseWriter, r *http.Reque writeJSON(w, http.StatusOK, result) } +// SearchSecurityReportAnalyses handles POST /security-report-analyses/search. +func (h *CaseHandler) SearchSecurityReportAnalyses(w http.ResponseWriter, r *http.Request) { + user := middleware.UserInfoFromContext(r.Context()) + if user == nil { + writeError(w, http.StatusUnauthorized, ErrMsgUnauthorized) + return + } + + r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodyBytes) + body, err := io.ReadAll(r.Body) + if err != nil { + if _, ok := err.(*http.MaxBytesError); ok { + writeError(w, http.StatusRequestEntityTooLarge, ErrMsgTooLarge) + return + } + writeError(w, http.StatusBadRequest, errMsgReadBody) + return + } + + if !json.Valid(body) { + writeError(w, http.StatusBadRequest, ErrMsgBadRequest) + return + } + + result, err := h.entity.SearchSecurityReportAnalyses(r.Context(), body) + if err != nil { + slog.ErrorContext(r.Context(), "entity SearchSecurityReportAnalyses failed", "userID", user.UserID, "err", err) + mapUpstreamError(w, err, "Failed to search security report analyses.") + return + } + + writeJSON(w, http.StatusOK, result) +} + +// SearchEngagements handles POST /engagements/search. +func (h *CaseHandler) SearchEngagements(w http.ResponseWriter, r *http.Request) { + user := middleware.UserInfoFromContext(r.Context()) + if user == nil { + writeError(w, http.StatusUnauthorized, ErrMsgUnauthorized) + return + } + + r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodyBytes) + body, err := io.ReadAll(r.Body) + if err != nil { + if _, ok := err.(*http.MaxBytesError); ok { + writeError(w, http.StatusRequestEntityTooLarge, ErrMsgTooLarge) + return + } + writeError(w, http.StatusBadRequest, errMsgReadBody) + return + } + + if !json.Valid(body) { + writeError(w, http.StatusBadRequest, ErrMsgBadRequest) + return + } + + result, err := h.entity.SearchEngagements(r.Context(), body) + if err != nil { + slog.ErrorContext(r.Context(), "entity SearchEngagements failed", "userID", user.UserID, "err", err) + mapUpstreamError(w, err, "Failed to search engagements.") + return + } + + writeJSON(w, http.StatusOK, result) +} + // CreateCaseAttachment handles POST /cases/{id}/attachments. func (h *CaseHandler) CreateCaseAttachment(w http.ResponseWriter, r *http.Request) { user := middleware.UserInfoFromContext(r.Context()) diff --git a/apps/csm-portal/backend/internal/handler/helpers_test.go b/apps/csm-portal/backend/internal/handler/helpers_test.go index c6676217bd..66ae72fbeb 100644 --- a/apps/csm-portal/backend/internal/handler/helpers_test.go +++ b/apps/csm-portal/backend/internal/handler/helpers_test.go @@ -90,8 +90,10 @@ type mockEntityCaseClient struct { createCaseCommentFn func(ctx context.Context, caseID string, body []byte) ([]byte, error) searchCaseCommentsFn func(ctx context.Context, caseID string, body []byte) ([]byte, error) searchCasesFn func(ctx context.Context, body []byte) ([]byte, error) - searchServiceRequestsFn func(ctx context.Context, body []byte) ([]byte, error) - getCaseFn func(ctx context.Context, caseID string) ([]byte, error) + searchServiceRequestsFn func(ctx context.Context, body []byte) ([]byte, error) + searchSecurityReportAnalysesFn func(ctx context.Context, body []byte) ([]byte, error) + searchEngagementsFn func(ctx context.Context, body []byte) ([]byte, error) + getCaseFn func(ctx context.Context, caseID string) ([]byte, error) createCaseAttachmentFn func(ctx context.Context, caseID string, body []byte) ([]byte, error) searchCaseAttachmentsFn func(ctx context.Context, caseID string, body []byte) ([]byte, error) getCaseAttachmentContentFn func(ctx context.Context, caseID, attachmentID string) ([]byte, string, error) @@ -139,6 +141,20 @@ func (m *mockEntityCaseClient) SearchServiceRequests(ctx context.Context, body [ return []byte(`{}`), nil } +func (m *mockEntityCaseClient) SearchSecurityReportAnalyses(ctx context.Context, body []byte) ([]byte, error) { + if m.searchSecurityReportAnalysesFn != nil { + return m.searchSecurityReportAnalysesFn(ctx, body) + } + return []byte(`{}`), nil +} + +func (m *mockEntityCaseClient) SearchEngagements(ctx context.Context, body []byte) ([]byte, error) { + if m.searchEngagementsFn != nil { + return m.searchEngagementsFn(ctx, body) + } + return []byte(`{}`), nil +} + func (m *mockEntityCaseClient) GetCase(ctx context.Context, caseID string) ([]byte, error) { if m.getCaseFn != nil { return m.getCaseFn(ctx, caseID) diff --git a/apps/csm-portal/backend/openapi.yaml b/apps/csm-portal/backend/openapi.yaml index e6190ae204..03003f0561 100644 --- a/apps/csm-portal/backend/openapi.yaml +++ b/apps/csm-portal/backend/openapi.yaml @@ -921,6 +921,118 @@ paths: schema: $ref: '#/components/schemas/ErrorPayload' + /security-report-analyses/search: + post: + summary: Search security report analyses. + description: Only supported for the ServiceNow data source; returns 503 otherwise. + operationId: postSecurityReportAnalysesSearch + requestBody: + description: Security report analysis search payload + content: + application/json: + schema: + $ref: '#/components/schemas/SecurityReportAnalysisSearchPayload' + required: true + responses: + "200": + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/SecurityReportAnalysisSearchResponse' + "400": + description: BadRequest + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorPayload' + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorPayload' + "403": + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorPayload' + "413": + description: RequestEntityTooLarge + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorPayload' + "500": + description: InternalServerError + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorPayload' + "503": + description: Upstream service unavailable (Postgres data source does not support security report analyses). + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorPayload' + + /engagements/search: + post: + summary: Search engagements. + description: Only supported for the ServiceNow data source; returns 503 otherwise. + operationId: postEngagementsSearch + requestBody: + description: Engagement search payload + content: + application/json: + schema: + $ref: '#/components/schemas/EngagementSearchPayload' + required: true + responses: + "200": + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/EngagementSearchResponse' + "400": + description: BadRequest + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorPayload' + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorPayload' + "403": + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorPayload' + "413": + description: RequestEntityTooLarge + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorPayload' + "500": + description: InternalServerError + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorPayload' + "503": + description: Upstream service unavailable (Postgres data source does not support engagements). + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorPayload' + /deployments/search: post: summary: Search deployments. @@ -2659,3 +2771,302 @@ components: type: integer limit: type: integer + + SecurityReportAnalysisSearchFilters: + type: object + properties: + searchQuery: + type: string + description: Searches across title and number (case-insensitive) + projectIds: + type: array + items: + type: string + format: uuid + description: Filter by project UUIDs (optional) + deploymentIds: + type: array + items: + type: string + format: uuid + description: Filter by deployment UUIDs (optional) + stateKeys: + type: array + items: + type: integer + description: Filter by state keys (optional) + closedStartDate: + type: string + format: date-time + description: Filter records closed on or after this UTC datetime (optional) + closedEndDate: + type: string + format: date-time + description: Filter records closed on or before this UTC datetime (optional) + startCreatedDate: + type: string + format: date-time + description: Filter records created on or after this UTC datetime (optional) + endCreatedDate: + type: string + format: date-time + description: Filter records created on or before this UTC datetime (optional) + startUpdatedDate: + type: string + format: date-time + description: Filter records updated on or after this UTC datetime (optional) + endUpdatedDate: + type: string + format: date-time + description: Filter records updated on or before this UTC datetime (optional) + createdBy: + type: array + items: + type: string + format: email + description: Filter to records created by these email addresses (optional) + createdByMe: + type: boolean + description: When true, the caller's email (from JWT) is appended to createdBy (optional) + + SecurityReportAnalysisSearchPayload: + type: object + nullable: true + properties: + filters: + $ref: '#/components/schemas/SecurityReportAnalysisSearchFilters' + sortBy: + type: object + properties: + field: + type: string + enum: [createdOn, updatedOn] + default: createdOn + order: + type: string + enum: [asc, desc] + default: desc + pagination: + allOf: + - $ref: '#/components/schemas/Pagination' + - properties: + limit: + default: 20 + maximum: 100 + + SecurityReportAnalysisView: + type: object + properties: + id: + type: string + format: uuid + internalId: + type: string + nullable: true + number: + type: string + title: + type: string + nullable: true + description: + type: string + nullable: true + createdOn: + type: string + format: date-time + createdBy: + type: string + state: + type: string + workState: + type: string + nullable: true + product: + allOf: + - $ref: '#/components/schemas/EntityRef' + nullable: true + project: + $ref: '#/components/schemas/EntityRef' + deployment: + $ref: '#/components/schemas/EntityRef' + deployedProduct: + $ref: '#/components/schemas/EntityRef' + assignedEngineer: + allOf: + - $ref: '#/components/schemas/AssignedEngineerRef' + nullable: true + parentCase: + allOf: + - $ref: '#/components/schemas/EntityRef' + nullable: true + relatedCase: + allOf: + - $ref: '#/components/schemas/EntityRef' + nullable: true + + SecurityReportAnalysisSearchResponse: + type: object + properties: + securityReportAnalyses: + type: array + items: + $ref: '#/components/schemas/SecurityReportAnalysisView' + totalRecords: + type: integer + description: Total number of matching security report analyses + offset: + type: integer + limit: + type: integer + + EngagementSearchFilters: + type: object + properties: + searchQuery: + type: string + description: Searches across title and number (case-insensitive) + projectIds: + type: array + items: + type: string + format: uuid + description: Filter by project UUIDs (optional) + deploymentIds: + type: array + items: + type: string + format: uuid + description: Filter by deployment UUIDs (optional) + typeKeys: + type: array + items: + type: string + enum: [migration, consultancy, new_feature_improvement, follow_up, onboarding] + description: Filter by engagement type keys (optional) + closedStartDate: + type: string + format: date-time + description: Filter engagements closed on or after this UTC datetime (optional) + closedEndDate: + type: string + format: date-time + description: Filter engagements closed on or before this UTC datetime (optional) + startCreatedDate: + type: string + format: date-time + description: Filter engagements created on or after this UTC datetime (optional) + endCreatedDate: + type: string + format: date-time + description: Filter engagements created on or before this UTC datetime (optional) + startUpdatedDate: + type: string + format: date-time + description: Filter engagements updated on or after this UTC datetime (optional) + endUpdatedDate: + type: string + format: date-time + description: Filter engagements updated on or before this UTC datetime (optional) + createdBy: + type: array + items: + type: string + format: email + description: Filter to engagements created by these email addresses (optional) + createdByMe: + type: boolean + description: When true, the caller's email (from JWT) is appended to createdBy (optional) + + EngagementSearchPayload: + type: object + nullable: true + properties: + filters: + $ref: '#/components/schemas/EngagementSearchFilters' + sortBy: + type: object + properties: + field: + type: string + enum: [createdOn, updatedOn] + default: createdOn + order: + type: string + enum: [asc, desc] + default: desc + pagination: + allOf: + - $ref: '#/components/schemas/Pagination' + - properties: + limit: + default: 20 + maximum: 100 + + EngagementView: + type: object + properties: + id: + type: string + format: uuid + internalId: + type: string + nullable: true + number: + type: string + title: + type: string + nullable: true + description: + type: string + nullable: true + createdOn: + type: string + format: date-time + createdBy: + type: string + state: + type: string + workState: + type: string + nullable: true + type: + type: string + nullable: true + description: Engagement type label returned by ServiceNow (e.g. "Migration", "Consultancy") + product: + allOf: + - $ref: '#/components/schemas/EntityRef' + nullable: true + project: + $ref: '#/components/schemas/EntityRef' + deployment: + $ref: '#/components/schemas/EntityRef' + deployedProduct: + $ref: '#/components/schemas/EntityRef' + assignedEngineer: + allOf: + - $ref: '#/components/schemas/AssignedEngineerRef' + nullable: true + parentCase: + allOf: + - $ref: '#/components/schemas/EntityRef' + nullable: true + relatedCase: + allOf: + - $ref: '#/components/schemas/EntityRef' + nullable: true + + EngagementSearchResponse: + type: object + properties: + engagements: + type: array + items: + $ref: '#/components/schemas/EngagementView' + totalRecords: + type: integer + description: Total number of matching engagements + offset: + type: integer + limit: + type: integer