diff --git a/apps/csm-portal/backend/cmd/server/main.go b/apps/csm-portal/backend/cmd/server/main.go index 6e180951a1..4a08a5bf08 100644 --- a/apps/csm-portal/backend/cmd/server/main.go +++ b/apps/csm-portal/backend/cmd/server/main.go @@ -145,6 +145,7 @@ func main() { mux.HandleFunc("DELETE /attachments/{id}", caseHandler.DeleteCaseAttachment) mux.HandleFunc("POST /cases/{id}/call-requests", caseHandler.CreateCallRequest) mux.HandleFunc("POST /cases/{id}/call-requests/search", caseHandler.SearchCallRequests) + mux.HandleFunc("POST /call-requests/search", caseHandler.SearchAllCallRequests) mux.HandleFunc("PATCH /cases/{caseId}/call-requests/{callRequestId}", caseHandler.PatchCallRequest) mux.HandleFunc("POST /cases/{id}/github-issues", caseHandler.CreateCaseGithubIssue) mux.HandleFunc("POST /cases/{id}/tags", caseHandler.AddCaseTag) diff --git a/apps/csm-portal/backend/internal/dashboard/registry.go b/apps/csm-portal/backend/internal/dashboard/registry.go index 90f9b12fa3..d97f3fae15 100644 --- a/apps/csm-portal/backend/internal/dashboard/registry.go +++ b/apps/csm-portal/backend/internal/dashboard/registry.go @@ -352,6 +352,7 @@ var validWidgetResourceTypes = map[ResourceType]bool{ ResourceCase: true, ResourceIncident: true, ResourceChangeRequest: true, ResourceAccount: true, ResourceProject: true, ResourceUser: true, ResourceTimeCard: true, ResourceProblem: true, ResourceProductVulnerability: true, + ResourceCallRequest: true, } var validWidgetShapes = map[Shape]bool{ diff --git a/apps/csm-portal/backend/internal/dashboard/widgets.go b/apps/csm-portal/backend/internal/dashboard/widgets.go index cc0c095333..e1fae01f12 100644 --- a/apps/csm-portal/backend/internal/dashboard/widgets.go +++ b/apps/csm-portal/backend/internal/dashboard/widgets.go @@ -50,6 +50,7 @@ const ( ResourceTimeCard ResourceType = "time_card" ResourceProblem ResourceType = "problem" ResourceProductVulnerability ResourceType = "product_vulnerability" + ResourceCallRequest ResourceType = "call_request" ) // Shape is how a widget's resolved data should be rendered. diff --git a/apps/csm-portal/backend/internal/entity/customer.go b/apps/csm-portal/backend/internal/entity/customer.go index 0183774d8e..292d20b5aa 100644 --- a/apps/csm-portal/backend/internal/entity/customer.go +++ b/apps/csm-portal/backend/internal/entity/customer.go @@ -347,6 +347,13 @@ func (c *CustomerEntityClient) SearchCallRequests(ctx context.Context, body []by return c.do(ctx, http.MethodPost, "/call-requests/search", body) } +// SearchAllCallRequests calls POST /call-requests/search-all on the entity service +// (standalone call request search, not scoped to a parent case). Response is +// returned as raw JSON; typed response structs are deferred. +func (c *CustomerEntityClient) SearchAllCallRequests(ctx context.Context, body []byte) ([]byte, error) { + return c.do(ctx, http.MethodPost, "/call-requests/search-all", body) +} + // PatchCallRequest calls PATCH /call-requests/{id} on the entity service. // Response is returned as raw JSON. func (c *CustomerEntityClient) PatchCallRequest(ctx context.Context, callRequestID string, body []byte) ([]byte, error) { diff --git a/apps/csm-portal/backend/internal/handler/cases.go b/apps/csm-portal/backend/internal/handler/cases.go index 1e8c320612..bcd39e2189 100644 --- a/apps/csm-portal/backend/internal/handler/cases.go +++ b/apps/csm-portal/backend/internal/handler/cases.go @@ -83,6 +83,7 @@ type entityCaseClient interface { DeleteCaseAttachment(ctx context.Context, attachmentID string) ([]byte, error) CreateCallRequest(ctx context.Context, body []byte) ([]byte, error) SearchCallRequests(ctx context.Context, body []byte) ([]byte, error) + SearchAllCallRequests(ctx context.Context, body []byte) ([]byte, error) PatchCallRequest(ctx context.Context, callRequestID string, body []byte) ([]byte, error) CreateCaseGithubIssue(ctx context.Context, caseID string, body []byte) ([]byte, error) AddCaseTag(ctx context.Context, caseID string, body []byte) ([]byte, error) @@ -884,6 +885,47 @@ func (h *CaseHandler) SearchCallRequests(w http.ResponseWriter, r *http.Request) writeJSON(w, http.StatusOK, result) } +// SearchAllCallRequests handles POST /call-requests/search — standalone call +// request search across all cases (not scoped to one case; see SearchCallRequests +// for that path, which is nested under /cases/{id}/). Raw pass-through +// body/response. Despite the shared "search" name with the case-scoped path, +// this is a distinct route (flat, no case-id path param) with no collision -- +// forwards to the entity service's own /call-requests/search-all, which keeps +// its "-all" suffix to stay distinct from ITS sibling case-scoped path. +func (h *CaseHandler) SearchAllCallRequests(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 { + var maxBytesErr *http.MaxBytesError + if errors.As(err, &maxBytesErr) { + writeError(w, http.StatusRequestEntityTooLarge, ErrMsgTooLarge) + return + } + writeError(w, http.StatusBadRequest, errMsgReadBody) + return + } + + if !isJSONObjectOrEmpty(body) { + writeError(w, http.StatusBadRequest, ErrMsgBadRequest) + return + } + + result, err := h.entity.SearchAllCallRequests(r.Context(), body) + if err != nil { + slog.ErrorContext(r.Context(), "entity SearchAllCallRequests failed", "userID", user.UserID, "err", err) + mapUpstreamErrorGeneric(w, err, "Failed to search call requests.") + return + } + + writeJSON(w, http.StatusOK, result) +} + // PatchCallRequest handles PATCH /cases/{id}/call-requests/{callRequestId}. // Forwards the body unchanged to the entity service's PATCH /call-requests/{callRequestId}. // diff --git a/apps/csm-portal/backend/internal/handler/helpers_test.go b/apps/csm-portal/backend/internal/handler/helpers_test.go index dbd6657733..1b94a3c37d 100644 --- a/apps/csm-portal/backend/internal/handler/helpers_test.go +++ b/apps/csm-portal/backend/internal/handler/helpers_test.go @@ -99,6 +99,7 @@ type mockEntityCaseClient struct { deleteCaseAttachmentFn func(ctx context.Context, attachmentID string) ([]byte, error) createCallRequestFn func(ctx context.Context, body []byte) ([]byte, error) searchCallRequestsFn func(ctx context.Context, body []byte) ([]byte, error) + searchAllCallRequestsFn func(ctx context.Context, body []byte) ([]byte, error) patchCallRequestFn func(ctx context.Context, callRequestID string, body []byte) ([]byte, error) createCaseGithubIssueFn func(ctx context.Context, caseID string, body []byte) ([]byte, error) addCaseTagFn func(ctx context.Context, caseID string, body []byte) ([]byte, error) @@ -197,6 +198,13 @@ func (m *mockEntityCaseClient) SearchCallRequests(ctx context.Context, body []by return []byte(`{"callRequests":[],"total":0,"limit":20,"offset":0}`), nil } +func (m *mockEntityCaseClient) SearchAllCallRequests(ctx context.Context, body []byte) ([]byte, error) { + if m.searchAllCallRequestsFn != nil { + return m.searchAllCallRequestsFn(ctx, body) + } + return []byte(`{"callRequests":[],"total":0,"limit":20,"offset":0}`), nil +} + func (m *mockEntityCaseClient) PatchCallRequest(ctx context.Context, callRequestID string, body []byte) ([]byte, error) { if m.patchCallRequestFn != nil { return m.patchCallRequestFn(ctx, callRequestID, body) diff --git a/apps/csm-portal/backend/openapi.yaml b/apps/csm-portal/backend/openapi.yaml index 73f76db293..d8ea1f1ee2 100644 --- a/apps/csm-portal/backend/openapi.yaml +++ b/apps/csm-portal/backend/openapi.yaml @@ -2299,6 +2299,55 @@ paths: schema: $ref: '#/components/schemas/ErrorPayload' + /call-requests/search: + post: + summary: Search call requests across all cases, filtered by assignee/state. + description: > + Returns a paginated list of call requests (ServiceNow data source only). Call + requests are inherently case-related; this endpoint provides independent search + and filtering capabilities across cases, distinct from + /cases/{id}/call-requests/search which is scoped to a single case. Forwards to + the entity service's /call-requests/search-all, which keeps its "-all" suffix + to stay distinct from that service's own case-scoped sibling path. + operationId: searchAllCallRequests + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SearchAllCallRequestsPayload' + responses: + "200": + description: Call requests matching the supplied filters. + content: + application/json: + schema: + $ref: '#/components/schemas/SearchCallRequestsResponse' + "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' + "500": + description: InternalServerError + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorPayload' + /cases/{caseId}/call-requests/{callRequestId}: patch: summary: Update a call request (ServiceNow data source only). Alias with caseId param name. @@ -5327,6 +5376,7 @@ components: - time_card - problem - product_vulnerability + - call_request description: Which resource's /search endpoint this widget's filters target shape: type: string @@ -8591,6 +8641,54 @@ components: default: 20 maximum: 100 + SearchAllCallRequestsPayload: + type: object + additionalProperties: false + properties: + filters: + type: object + additionalProperties: false + properties: + assignedUserIds: + type: array + items: + type: string + format: uuid + description: Filter by the parent case's assigned user(s). + states: + type: array + items: + type: string + enum: + - pending_on_customer + - pending_on_wso2 + - scheduled + - customer_rejected + - wso2_rejected + - canceled + - notes_pending + - concluded + description: Filter by one or more states. + sortBy: + type: object + additionalProperties: false + properties: + field: + type: string + enum: [createdOn, updatedOn, scheduleTime] + description: Field to sort by. + order: + type: string + enum: [asc, desc] + description: Sort direction. + pagination: + allOf: + - $ref: '#/components/schemas/Pagination' + - properties: + limit: + default: 20 + maximum: 100 + CallRequestView: type: object properties: diff --git a/apps/csm-portal/webapp/src/api/backend/types.ts b/apps/csm-portal/webapp/src/api/backend/types.ts index a92f63ef87..fb5e7fc6be 100644 --- a/apps/csm-portal/webapp/src/api/backend/types.ts +++ b/apps/csm-portal/webapp/src/api/backend/types.ts @@ -2770,7 +2770,8 @@ export type BeWidgetResourceType = | "time_card" | "problem" | "product_vulnerability" - | "task"; + | "task" + | "call_request"; /** * How a widget's resolved data should be rendered. `pie` and `bar` both diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.tsx index fcf71e89f5..80f72f46e7 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.tsx +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.tsx @@ -112,6 +112,9 @@ export default function DashboardWidgetTile({ selectedTeamGroupId, ); const config = WIDGET_RESOURCE_CONFIG[resourceType]; + // Thousands separators for shape "count"'s big number -- used both in the + // visible Typography and the tile's aria-label, so both stay in sync. + const formattedCount = (data?.total ?? 0).toLocaleString(); if (!config) { // resourceType came from a runtime-configurable backend registry (not a @@ -385,7 +388,7 @@ export default function DashboardWidgetTile({ textOverflow: "ellipsis", }} > - {data?.total ?? 0} + {formattedCount} @@ -414,7 +417,7 @@ export default function DashboardWidgetTile({ // layer above this anchor, not inside it as descendant text anymore // (that's the whole point -- see the comment above), so it needs its // own accessible name instead of inheriting one from its content. - aria-label={`${displayName}: ${data?.total ?? 0}`} + aria-label={`${displayName}: ${formattedCount}`} sx={{ position: "absolute", inset: 0, diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/config/widgetListConfig.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/config/widgetListConfig.tsx index f96c7901ef..461a946f3e 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/config/widgetListConfig.tsx +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/config/widgetListConfig.tsx @@ -55,6 +55,7 @@ import { normalizeUser, type User, type SnUser } from "@features/csm-users/types import UserRefLink from "@components/UserRefLink"; import { vulnerabilityPriorityColor } from "@features/csm-security-center/utils/vulnerabilities"; import type { BeProductVulnerabilityView } from "@api/backend/types"; +import type { BeCallRequestView } from "@api/backend/types"; /** Raw item shape a dashboard widget's `/search` response resolves to — * matches `WidgetItem` in `widgetResourceConfig.ts` (kept loose there since @@ -70,6 +71,21 @@ function formatDate(value?: string | null): string { ); } +/** Date + time, for columns where same-day values must stay distinguishable + * (e.g. a call request's scheduled time) -- `formatDate` alone drops the + * hour/minute and collapses same-day rows to an identical-looking value. */ +function formatDateTime(value?: string | null): string { + return ( + formatBackendTimestampForDisplay(value, { + year: "numeric", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }) ?? "—" + ); +} + export interface WidgetListRendererProps { items: WidgetItem[]; isLoading: boolean; @@ -454,6 +470,47 @@ function TaskWidgetList({ items, isLoading }: WidgetListRendererProps): JSX.Elem ); } +/** Call request: unlike task, `CallRequestView.case.id` is always present, so + * rows navigate straight to the owning case's real detail page rather than + * opening a dialog. */ +function CallRequestWidgetList({ items, isLoading }: WidgetListRendererProps): JSX.Element { + const callRequests = items as unknown as BeCallRequestView[]; + return ( + ({ + key: cr.id ?? `call-request-${i}`, + href: cr.case?.id ? `/cases/${cr.case.id}` : undefined, + cells: [ + + {cr.number || "—"} + , + + {cr.reason || "—"} + , + cr.state?.label ? ( + + ) : ( + + — + + ), + + {formatDateTime(cr.scheduleTime)} + , + ], + }))} + /> + ); +} + /** Per-resourceType renderer for a `shape: "list"` dashboard widget. Every * resource type is covered — `WIDGET_RESOURCE_CONFIG` (in * `widgetResourceConfig.ts`) is keyed the same way, so a missing entry here @@ -472,4 +529,5 @@ export const WIDGET_LIST_RENDERERS: Record< time_card: TimeCardWidgetList, product_vulnerability: ProductVulnerabilityWidgetList, task: TaskWidgetList, + call_request: CallRequestWidgetList, }; diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/config/widgetResourceConfig.ts b/apps/csm-portal/webapp/src/features/csm-dashboard/config/widgetResourceConfig.ts index 252a39ff21..360e8dde1d 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/config/widgetResourceConfig.ts +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/config/widgetResourceConfig.ts @@ -470,6 +470,28 @@ export const WIDGET_RESOURCE_CONFIG: Record< iconColor: "warning", previewSlug: "tasks", }, + call_request: { + searchEndpoint: "/call-requests/search", + itemsKey: "callRequests", + primaryLabel: (item) => { + const number = asString(item.number); + const reason = asString(item.reason); + return [number, reason].filter(Boolean).join(" — ") || "—"; + }, + secondaryLabel: (item) => { + const state = item.state as { label?: string } | undefined; + return state?.label; + }, + // No widget filters a call request by anything the cases list can render as a + // filtered view (state keys differ entirely from case state), so the tile-level + // "view all" click has nowhere sensible to land other than the dashboard itself + // -- unlike a per-row click, which goes straight to the owning case (see the + // list renderer in widgetListConfig.tsx, not this file). + buildHref: () => "/dashboard", + icon: Clock, + iconColor: "info", + previewSlug: "call-requests", + }, }; /** Reverse lookup of `previewSlug` back to its `resourceType`, for the diff --git a/entity-service/internal/domain/entity.go b/entity-service/internal/domain/entity.go index 25d64a370d..69bac0114b 100644 --- a/entity-service/internal/domain/entity.go +++ b/entity-service/internal/domain/entity.go @@ -2880,6 +2880,44 @@ type SearchCallRequestsResponse struct { Limit int `json:"limit"` } +// CallRequestSortField enumerates the columns available for sorting a +// cross-case call request search. +type CallRequestSortField string + +const ( + CallRequestSortFieldCreatedOn CallRequestSortField = "createdOn" + CallRequestSortFieldUpdatedOn CallRequestSortField = "updatedOn" + CallRequestSortFieldScheduleTime CallRequestSortField = "scheduleTime" +) + +// CallRequestSortOrder controls the sort direction for call request search. +type CallRequestSortOrder string + +const ( + CallRequestSortOrderAsc CallRequestSortOrder = "asc" + CallRequestSortOrderDesc CallRequestSortOrder = "desc" +) + +// CallRequestSort specifies the sort field and direction for call request search results. +type CallRequestSort struct { + Field CallRequestSortField `json:"field"` + Order CallRequestSortOrder `json:"order"` +} + +// SearchAllCallRequestsFilters holds optional filter criteria for the +// standalone (not case-scoped) call request search. +type SearchAllCallRequestsFilters struct { + AssignedUserIDs []string `json:"assignedUserIds"` + States []CallRequestStateType `json:"states"` +} + +// SearchAllCallRequestsRequest is the input for POST /call-requests/search-all. +type SearchAllCallRequestsRequest struct { + Filters SearchAllCallRequestsFilters `json:"filters"` + SortBy CallRequestSort `json:"sortBy"` + Pagination Pagination `json:"pagination"` +} + // UpdateCallRequestRequest is the input for PATCH /call-requests/{id}. // ID is injected from the URL path parameter and excluded from JSON decoding. // CaseID is optional; when provided the SN service verifies the call request diff --git a/entity-service/internal/handler/call_request_handler.go b/entity-service/internal/handler/call_request_handler.go index 7a200db2ff..d83e0fcf01 100644 --- a/entity-service/internal/handler/call_request_handler.go +++ b/entity-service/internal/handler/call_request_handler.go @@ -67,6 +67,21 @@ func (h *CallRequestHandler) SearchCallRequests(w http.ResponseWriter, r *http.R _ = json.NewEncoder(w).Encode(resp) } +// SearchAllCallRequests handles POST /call-requests/search-all. +func (h *CallRequestHandler) SearchAllCallRequests(w http.ResponseWriter, r *http.Request) { + var req domain.SearchAllCallRequestsRequest + if !decodeRequest(w, r, &req) { + return + } + resp, err := h.svc.SearchAllCallRequests(r.Context(), req) + if err != nil { + writeServiceError(w, r, err) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} + // PatchCallRequest handles PATCH /call-requests/{id}. func (h *CallRequestHandler) PatchCallRequest(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") diff --git a/entity-service/internal/server/routes.go b/entity-service/internal/server/routes.go index b64eaa47d7..bc170aa557 100644 --- a/entity-service/internal/server/routes.go +++ b/entity-service/internal/server/routes.go @@ -283,6 +283,7 @@ func NewRouter(db *pgxpool.Pool, cfg *config.Config) http.Handler { if callRequestHandler != nil { mux.HandleFunc("POST /call-requests", callRequestHandler.CreateCallRequest) mux.HandleFunc("POST /call-requests/search", callRequestHandler.SearchCallRequests) + mux.HandleFunc("POST /call-requests/search-all", callRequestHandler.SearchAllCallRequests) mux.HandleFunc("PATCH /call-requests/{id}", callRequestHandler.PatchCallRequest) } diff --git a/entity-service/internal/service/interfaces.go b/entity-service/internal/service/interfaces.go index b62b615fe1..94e460bc32 100644 --- a/entity-service/internal/service/interfaces.go +++ b/entity-service/internal/service/interfaces.go @@ -262,6 +262,11 @@ type CallRequestService interface { // SearchCallRequests returns a paginated list of call requests for the given case. // A ValidationError is returned for invalid input. SearchCallRequests(ctx context.Context, req domain.SearchCallRequestsRequest) (domain.SearchCallRequestsResponse, error) + // SearchAllCallRequests returns a paginated list of call requests across all + // cases, filtered by assignee/state -- distinct from SearchCallRequests, which + // is scoped to one case and has no filter set of its own. + // A ValidationError is returned for invalid input. + SearchAllCallRequests(ctx context.Context, req domain.SearchAllCallRequestsRequest) (domain.SearchCallRequestsResponse, error) // UpdateCallRequest updates the state or other fields of a call request. // The target state selects the behaviour (customer/agent transitions, scheduling, // rejection, conclusion with notes). A ValidationError is returned for invalid diff --git a/entity-service/internal/service/sn_call_request_service.go b/entity-service/internal/service/sn_call_request_service.go index 767e34d9f5..59a9b1134a 100644 --- a/entity-service/internal/service/sn_call_request_service.go +++ b/entity-service/internal/service/sn_call_request_service.go @@ -67,6 +67,24 @@ type snCallRequestSearchFilters struct { StateKeys []int `json:"stateKeys,omitempty"` } +// snCallRequestsSearchAllPayload mirrors POST /call-requests/search-all in the SN +// integration service (standalone, not case-scoped). +type snCallRequestsSearchAllPayload struct { + Filters *snCallRequestsSearchAllFilters `json:"filters,omitempty"` + SortBy *snCallRequestSort `json:"sortBy,omitempty"` + Pagination snProjectPagination `json:"pagination"` +} + +type snCallRequestsSearchAllFilters struct { + AssignedUserIDs []string `json:"assignedUserIds,omitempty"` + StateKeys []int `json:"stateKeys,omitempty"` +} + +type snCallRequestSort struct { + Field string `json:"field"` + Order string `json:"order"` +} + // snCallRequestsResponse mirrors the SN integration service POST /call-requests/search response. type snCallRequestsResponse struct { CallRequests []snCallRequest `json:"callRequests"` @@ -283,8 +301,23 @@ func (s *snCallRequestService) SearchCallRequests(ctx context.Context, req domai return domain.SearchCallRequestsResponse{}, fmt.Errorf("sn call requests: parse search response: %w", err) } - views := make([]domain.CallRequestView, 0, len(snResp.CallRequests)) - for _, cr := range snResp.CallRequests { + views := mapSNCallRequestsToViews(snResp.CallRequests) + + total := snResp.TotalRecords + return domain.SearchCallRequestsResponse{ + CallRequests: views, + Total: total, + Limit: req.Pagination.Limit, + Offset: req.Pagination.Offset, + }, nil +} + +// mapSNCallRequestsToViews converts raw SN call request records to domain views. +// Shared by SearchCallRequests (case-scoped) and SearchAllCallRequests (cross-case) -- +// both call the same underlying SN response shape. +func mapSNCallRequestsToViews(crs []snCallRequest) []domain.CallRequestView { + views := make([]domain.CallRequestView, 0, len(crs)) + for _, cr := range crs { views = append(views, domain.CallRequestView{ ID: sysidToUUID(cr.ID), Number: cr.Number, @@ -310,13 +343,89 @@ func (s *snCallRequestService) SearchCallRequests(ctx context.Context, req domai ActualDurationMin: cr.ActualDurationMin, }) } + return views +} + +// validCallRequestSortField is the set of accepted CallRequestSortField values. +var validCallRequestSortField = map[domain.CallRequestSortField]bool{ + domain.CallRequestSortFieldCreatedOn: true, + domain.CallRequestSortFieldUpdatedOn: true, + domain.CallRequestSortFieldScheduleTime: true, +} + +// validCallRequestSortOrder is the set of accepted CallRequestSortOrder values. +var validCallRequestSortOrder = map[domain.CallRequestSortOrder]bool{ + domain.CallRequestSortOrderAsc: true, + domain.CallRequestSortOrderDesc: true, +} + +// SearchAllCallRequests implements CallRequestService. +func (s *snCallRequestService) SearchAllCallRequests(ctx context.Context, req domain.SearchAllCallRequestsRequest) (domain.SearchCallRequestsResponse, error) { + if err := normalizePagination(&req.Pagination); err != nil { + return domain.SearchCallRequestsResponse{}, err + } + + token := middleware.UserIDTokenFromContext(ctx) + + if err := validateUUIDs("filters.assignedUserIds", req.Filters.AssignedUserIDs); err != nil { + return domain.SearchCallRequestsResponse{}, err + } + + if req.SortBy.Field == "" { + req.SortBy.Field = domain.CallRequestSortFieldUpdatedOn + } else if !validCallRequestSortField[req.SortBy.Field] { + return domain.SearchCallRequestsResponse{}, &apierror.ValidationError{Msg: "sortBy.field must be one of: createdOn, updatedOn, scheduleTime"} + } + if req.SortBy.Order == "" { + req.SortBy.Order = domain.CallRequestSortOrderDesc + } else if !validCallRequestSortOrder[req.SortBy.Order] { + return domain.SearchCallRequestsResponse{}, &apierror.ValidationError{Msg: "sortBy.order must be one of: asc, desc"} + } + + payload := snCallRequestsSearchAllPayload{ + Pagination: snProjectPagination{Limit: req.Pagination.Limit, Offset: req.Pagination.Offset}, + SortBy: &snCallRequestSort{ + Field: string(req.SortBy.Field), + Order: string(req.SortBy.Order), + }, + } + + var filters snCallRequestsSearchAllFilters + hasFilters := false + if len(req.Filters.AssignedUserIDs) > 0 { + filters.AssignedUserIDs = uuidsToSysids(req.Filters.AssignedUserIDs) + hasFilters = true + } + if len(req.Filters.States) > 0 { + keys := make([]int, 0, len(req.Filters.States)) + for _, st := range req.Filters.States { + if _, ok := validCallRequestStates[st]; !ok { + return domain.SearchCallRequestsResponse{}, &apierror.ValidationError{Msg: fmt.Sprintf("invalid state %q", st)} + } + keys = append(keys, callRequestStateToKey[st]) + } + filters.StateKeys = keys + hasFilters = true + } + if hasFilters { + payload.Filters = &filters + } + + raw, err := s.client.Post(ctx, "/call-requests/search-all", token, payload) + if err != nil { + return domain.SearchCallRequestsResponse{}, err + } + + var snResp snCallRequestsResponse + if err := json.Unmarshal(raw, &snResp); err != nil { + return domain.SearchCallRequestsResponse{}, fmt.Errorf("sn call requests: parse search-all response: %w", err) + } - total := snResp.TotalRecords return domain.SearchCallRequestsResponse{ - CallRequests: views, - Total: total, - Limit: req.Pagination.Limit, + CallRequests: mapSNCallRequestsToViews(snResp.CallRequests), + Total: snResp.TotalRecords, Offset: req.Pagination.Offset, + Limit: req.Pagination.Limit, }, nil } diff --git a/entity-service/openapi.yaml b/entity-service/openapi.yaml index abbb195ebb..68266a79b3 100644 --- a/entity-service/openapi.yaml +++ b/entity-service/openapi.yaml @@ -1490,6 +1490,47 @@ paths: schema: $ref: '#/components/schemas/ErrorResponse' + /call-requests/search-all: + post: + summary: Search call requests across all cases, filtered by assignee/state. + description: > + Returns a paginated list of call requests (ServiceNow data source only). Call + requests are inherently case-related; this endpoint provides independent search + and filtering capabilities across cases, distinct from /call-requests/search + which is scoped to a single case. + operationId: searchAllCallRequests + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SearchAllCallRequestsRequest' + responses: + "200": + description: Call requests matching the supplied filters. + content: + application/json: + schema: + $ref: '#/components/schemas/SearchCallRequestsResponse' + "400": + description: Bad request. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + "401": + description: Unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + "500": + description: Internal server error. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /call-requests/{id}: patch: summary: Update a call request (supported by the backing data source only). @@ -6761,6 +6802,57 @@ components: limit: type: integer + CallRequestSort: + type: object + additionalProperties: false + properties: + field: + type: string + enum: [createdOn, updatedOn, scheduleTime] + default: updatedOn + description: Field to sort by. Defaults to updatedOn when omitted. + order: + type: string + enum: [asc, desc] + default: desc + description: Sort direction. Defaults to desc when omitted. + + SearchAllCallRequestsFilters: + type: object + additionalProperties: false + properties: + assignedUserIds: + type: array + items: + type: string + format: uuid + description: Filter by the parent case's assigned user(s). + states: + type: array + items: + type: string + enum: + - pending_on_customer + - pending_on_wso2 + - scheduled + - customer_rejected + - wso2_rejected + - canceled + - notes_pending + - concluded + description: Filter by one or more call request states. + + SearchAllCallRequestsRequest: + type: object + additionalProperties: false + properties: + filters: + $ref: '#/components/schemas/SearchAllCallRequestsFilters' + sortBy: + $ref: '#/components/schemas/CallRequestSort' + pagination: + $ref: '#/components/schemas/Pagination' + UpdateCallRequestRequest: type: object required: [state]