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
1 change: 1 addition & 0 deletions apps/csm-portal/backend/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions apps/csm-portal/backend/internal/dashboard/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
1 change: 1 addition & 0 deletions apps/csm-portal/backend/internal/dashboard/widgets.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions apps/csm-portal/backend/internal/entity/customer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
42 changes: 42 additions & 0 deletions apps/csm-portal/backend/internal/handler/cases.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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}.
//
Expand Down
8 changes: 8 additions & 0 deletions apps/csm-portal/backend/internal/handler/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
98 changes: 98 additions & 0 deletions apps/csm-portal/backend/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Comment thread
coderabbitai[bot] marked this conversation as resolved.
/cases/{caseId}/call-requests/{callRequestId}:
patch:
summary: Update a call request (ServiceNow data source only). Alias with caseId param name.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion apps/csm-portal/webapp/src/api/backend/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -385,7 +388,7 @@ export default function DashboardWidgetTile({
textOverflow: "ellipsis",
}}
>
{data?.total ?? 0}
{formattedCount}
</Typography>
</Box>
</Box>
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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 (
<DashboardMiniTable
isLoading={isLoading}
emptyMessage="No call requests match this widget's filters."
columns={[
{ label: "Number", width: "minmax(90px, 0.7fr)" },
{ label: "Reason", width: "minmax(160px, 2fr)" },
{ label: "State", width: "minmax(100px, 1fr)" },
{ label: "Scheduled", width: "minmax(90px, 1fr)" },
]}
rows={callRequests.map((cr, i) => ({
key: cr.id ?? `call-request-${i}`,
href: cr.case?.id ? `/cases/${cr.case.id}` : undefined,
cells: [
<Typography key="number" variant="body2" noWrap>
{cr.number || "—"}
</Typography>,
<Typography key="reason" variant="body2" noWrap title={cr.reason ?? undefined}>
{cr.reason || "—"}
</Typography>,
cr.state?.label ? (
<Chip key="state" size="small" variant="outlined" label={cr.state.label} />
) : (
<Typography key="state" variant="body2">
—
</Typography>
),
<Typography key="scheduled" variant="caption" color="text.secondary" noWrap>
{formatDateTime(cr.scheduleTime)}
</Typography>,
],
}))}
/>
);
}

/** 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
Expand All @@ -472,4 +529,5 @@ export const WIDGET_LIST_RENDERERS: Record<
time_card: TimeCardWidgetList,
product_vulnerability: ProductVulnerabilityWidgetList,
task: TaskWidgetList,
call_request: CallRequestWidgetList,
};
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading