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
6 changes: 5 additions & 1 deletion apps/csm-portal/backend/internal/handler/cases.go
Original file line number Diff line number Diff line change
Expand Up @@ -656,7 +656,11 @@ func (h *CaseHandler) SearchTags(w http.ResponseWriter, r *http.Request) {
}

// PatchCase handles PATCH /cases/{id}.
// Accepts state, severity, workState, watchList, or assigneeEmail and forwards to the entity service.
// Accepts state, severity, workState, watchList, assigneeEmail, or acknowledge and forwards
// to the entity service. The body is forwarded verbatim, so fields with no local guard (like
// acknowledge, whose first-write-wins semantics and role gate both live upstream) need no
// handling here — only state and workState are pre-validated, because their guards depend on
// the case's current state.
func (h *CaseHandler) PatchCase(w http.ResponseWriter, r *http.Request) {
user := middleware.UserInfoFromContext(r.Context())
if user == nil {
Expand Down
67 changes: 61 additions & 6 deletions apps/csm-portal/backend/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -129,9 +129,9 @@ paths:
summary: Update a support case.
description: |
Updates exactly one field of the case. Provide exactly one of: `state`, `severity`,
`workState`, `watchList`, `assigneeEmail`, `parentId`, `subject`, `description`,
`deploymentId`, `deployedProductId`, `relatedCaseId`, `autocloseHoldUntil`,
`bestCaseFixEta`, `mostLikelyFixEta`, or `worstCaseFixEta`.
`workState`, `watchList`, `assigneeEmail`, `parentId`, `acknowledge`, `subject`,
`description`, `deploymentId`, `deployedProductId`, `relatedCaseId`,
`autocloseHoldUntil`, `bestCaseFixEta`, `mostLikelyFixEta`, or `worstCaseFixEta`.
`watchList`, `assigneeEmail`, `parentId`, and `autocloseHoldUntil` are supported only when
the ServiceNow data source is active.
operationId: patchCasesId
Expand Down Expand Up @@ -4714,9 +4714,9 @@ components:
type: object
description: |
Exactly one of `state`, `severity`, `workState`, `watchList`, `assigneeEmail`, `parentId`,
`subject`, `description`, `deploymentId`, `deployedProductId`, `relatedCaseId`,
`autocloseHoldUntil`, `bestCaseFixEta`, `mostLikelyFixEta`, or `worstCaseFixEta`
must be provided. `watchList`, `assigneeEmail`, `parentId`, and `autocloseHoldUntil` are
`acknowledge`, `subject`, `description`, `deploymentId`, `deployedProductId`,
`relatedCaseId`, `autocloseHoldUntil`, `bestCaseFixEta`, `mostLikelyFixEta`, or
`worstCaseFixEta` must be provided. `watchList`, `assigneeEmail`, `parentId`, and `autocloseHoldUntil` are
supported only for the ServiceNow data source.
`resolutionCode`, `cause`, and `closeNotes` are optional resolution fields that may only be
provided alongside `state: closed` or `state: solution_proposed`.
Expand All @@ -4727,6 +4727,7 @@ components:
- required: [watchList]
- required: [assigneeEmail]
- required: [parentId]
- required: [acknowledge]
- required: [subject]
- required: [description]
- required: [deploymentId]
Expand Down Expand Up @@ -4759,6 +4760,17 @@ components:
type: string
format: email
description: Email of the engineer to assign to this case (ServiceNow only).
acknowledge:
type: boolean
enum: [true]
description: |
Acknowledge the case as the calling engineer: a first-write-wins claim that
someone has seen it and picked it up, which is distinct from assignment.
Succeeds without changing anything if the case is already acknowledged, in
which case the response carries `alreadyAcknowledged: true`. Only `true` is
accepted; there is no way to remove an acknowledgement. Requires an elevated
support role, and is supported only by data sources that record
acknowledgement.
resolutionCode:
$ref: '#/components/schemas/CaseResolutionCode'
description: Resolution code — only allowed when state is closed or solution_proposed.
Expand Down Expand Up @@ -4879,6 +4891,22 @@ components:
nullable: true
allOf:
- $ref: '#/components/schemas/UserReference'
number:
type: string
description: Human-readable case number. Returned only when the update acknowledged the case.
alreadyAcknowledged:
type: boolean
description: |
True when the case already had an acknowledger, so the request changed nothing
and `acknowledgedBy` names whoever claimed it first. Returned only when the
update set `acknowledge`.
acknowledgedBy:
nullable: true
allOf:
- $ref: '#/components/schemas/AssignedEngineerRef'
description: |
Engineer who now holds the acknowledgement, whether this request set it or
found it already set. Returned only when the update set `acknowledge`.
resolutionCode:
$ref: '#/components/schemas/CaseResolutionCode'
nullable: true
Expand Down Expand Up @@ -5123,6 +5151,9 @@ components:
- integrationCsTeam
- resolutionNotes
- parentId
- taskSLABusinessElapsedPercent
- escalationLevel
- escalation
op:
type: string
enum: [eq, in, notIn, isEmpty, isNotEmpty, gte, lte]
Expand Down Expand Up @@ -5194,6 +5225,22 @@ components:
is no distinct "resolution notes present" behavior to express.
- **parentId** (eq): a single case UUID; matches child cases of that
case (the hierarchical major-case/child-case relationship).
- **taskSLABusinessElapsedPercent** (gte / lte): a non-negative
integer. Matches cases with at least one Task SLA record whose
businessElapsedPercent falls within the given bound(s) (both bounds
may be supplied together to express a range, e.g. gte 75 + lte 100
for "at risk or breached"). Not capped at 100: a long-overdue,
never-resolved SLA's percentage keeps climbing well past it (values
in the tens of thousands are real, observed data), so 100 means
"breached", not "the maximum possible value". Only applied by the
ServiceNow data source (a Task SLA table join); filtering logic is
confined to the SN adapter.
- **escalationLevel** (in): one or more escalation level ids, "0"
(EL0, not escalated) through "5" (EL5, CEO). Matches cases whose
current escalation level is any of the given ids.
- **escalation** (isEmpty / isNotEmpty): values ignored; isEmpty
matches cases with no active escalation, isNotEmpty matches cases
with an active escalation.

CaseSearchFilters:
type: object
Expand Down Expand Up @@ -5657,6 +5704,14 @@ components:
nullable: true
allOf:
- $ref: '#/components/schemas/UserReference'
acknowledgedBy:
nullable: true
allOf:
- $ref: '#/components/schemas/AssignedEngineerRef'
description: |
Engineer who acknowledged the case, or null if nobody has. Cleared by the
backing data source when the case's type or severity changes or it is
reopened, so a materially changed case has to be acknowledged again.
parentCase:
$ref: '#/components/schemas/CaseNumberRef'
relatedCase:
Expand Down
32 changes: 31 additions & 1 deletion apps/csm-portal/webapp/src/api/backend/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,14 @@ export interface BeCaseView {
/** Canonical reference to the assigned engineer, `id` populated. See
* {@link BeUserReference}. */
assignedEngineerUser?: BeUserReference | null;
/**
* The CS engineer who acknowledged the case — a first-write-wins claim that
* someone has seen it and picked it up, which is **not** the same as being
* assigned to it. `null` until someone acknowledges. The backing data source
* clears it again when the case's type or severity changes or it is reopened,
* so a materially changed case has to be acknowledged afresh.
*/
acknowledgedBy?: BeAssignedEngineerRef | null;
account?: BeCaseAccountRef;
project?: BeEntityRef;
/** Nullable: ServiceNow-sourced cases may have no deployment / product. */
Expand Down Expand Up @@ -618,6 +626,7 @@ interface BeCaseUpdateNever {
addPublicComment?: never;
product?: never;
publicTicket?: never;
acknowledge?: never;
}

/**
Expand Down Expand Up @@ -664,6 +673,14 @@ export type BeCaseUpdatePayload =
| (Omit<BeCaseUpdateNever, "deployedProductId"> & { deployedProductId: string })
/** UUID of another case to cross-link to this one as a related case (looser than `parentId`; ServiceNow only). */
| (Omit<BeCaseUpdateNever, "relatedCaseId"> & { relatedCaseId: string })
/**
* Acknowledge the case as the signed-in engineer. Typed as the literal `true`
* because that is the only accepted value: acknowledgement is first-write-wins
* and there is no way to remove one. Acknowledging an already-acknowledged
* case succeeds and changes nothing, and the response then carries
* `alreadyAcknowledged: true` with whoever claimed it first.
*/
| (Omit<BeCaseUpdateNever, "acknowledge"> & { acknowledge: true })
/**
* Places the case on hold in the backing data source's staged auto-closure
* sequence until this ISO date-time (ServiceNow only). The raw
Expand Down Expand Up @@ -732,6 +749,16 @@ export interface BeUpdatedCase {
mostLikelyFixEta?: string | null;
/** Echoes the updated internal-only worst-case fix estimate. Present when the update set `worstCaseFixEta`. */
worstCaseFixEta?: string | null;
/** Human-readable case number. Present when the update set `acknowledge`. */
number?: string;
/**
* True when the case already had an acknowledger, so the request changed
* nothing and `acknowledgedBy` names whoever claimed it first. Present when
* the update set `acknowledge`.
*/
alreadyAcknowledged?: boolean;
/** Whoever now holds the acknowledgement. Present when the update set `acknowledge`. */
acknowledgedBy?: BeAssignedEngineerRef | null;
}

/** `PATCH /cases/{id}` response: a message plus the mutated case fields. */
Expand Down Expand Up @@ -772,7 +799,10 @@ export type BeCaseFieldFilterField =
| "projectType"
| "integrationCsTeam"
| "resolutionNotes"
| "parentId";
| "parentId"
| "taskSLABusinessElapsedPercent"
| "escalationLevel"
| "escalation";
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* `op` enum accepted by {@link BeCaseFieldFilter}, independent of `field` —
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,14 @@ function detailFromBeCase(
linkedChangeRequests: c.linkedChangeRequests ?? undefined,
autoclosureStep: c.autoclosureStep ?? undefined,
autoclosureStateTime: c.autoclosureStateTime ?? undefined,
acknowledgedBy: c.acknowledgedBy
? {
// Fall back to the email when the data source returns a blank display
// name, so the UI never renders "Acknowledged by" with nothing after it.
name: c.acknowledgedBy.name?.trim() || (c.acknowledgedBy.email ?? "—"),
email: c.acknowledgedBy.email ?? undefined,
}
: undefined,
assignee,
assigneeName,
assigneeEmail,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -566,3 +566,89 @@ describe("CaseActionBar — Change severity is blocked on a closed case", () =>
expect(onAction).toHaveBeenCalledWith({ secondary: "change_severity" });
});
});

describe("acknowledge action", () => {
const renderBar = (
overrides: Partial<CsmCaseDetail>,
props: { onAcknowledge?: () => void; isAcknowledging?: boolean } = {},
): void => {
render(
<CaseActionBar
caseDetail={{ ...BASE_CASE, ...overrides }}
onAction={vi.fn()}
onAcknowledge={props.onAcknowledge ?? vi.fn()}
isAcknowledging={props.isAcknowledging}
/>,
);
};

it("offers acknowledge on an unacknowledged S0-S3 case", () => {
for (const severity of ["S0", "S1", "S2", "S3"] as const) {
const { unmount } = render(
<CaseActionBar
caseDetail={{ ...BASE_CASE, severity, acknowledgedBy: undefined }}
onAction={vi.fn()}
onAcknowledge={vi.fn()}
/>,
);
expect(
screen.getByRole("button", { name: /acknowledge/i }),
`expected the acknowledge button on a ${severity} case`,
).toBeInTheDocument();
unmount();
}
});

it("hides acknowledge on S4 — those cases raise no acknowledgement notification", () => {
renderBar({ severity: "S4", acknowledgedBy: undefined });
expect(screen.queryByRole("button", { name: /acknowledge/i })).not.toBeInTheDocument();
});

it("hides acknowledge once the case is acknowledged — it is first-write-wins, so there is nothing left to do", () => {
renderBar({ severity: "S1", acknowledgedBy: { name: "Jane Doe" } });
expect(screen.queryByRole("button", { name: /acknowledge/i })).not.toBeInTheDocument();
});

it("renders no acknowledge button when the caller wires no handler, rather than a dead control", () => {
render(
<CaseActionBar
caseDetail={{ ...BASE_CASE, severity: "S1", acknowledgedBy: undefined }}
onAction={vi.fn()}
/>,
);
expect(screen.queryByRole("button", { name: /acknowledge/i })).not.toBeInTheDocument();
});

it("invokes the handler on click and disables the button while in flight", () => {
const onAcknowledge = vi.fn();
const { unmount } = render(
<CaseActionBar
caseDetail={{ ...BASE_CASE, severity: "S1", acknowledgedBy: undefined }}
onAction={vi.fn()}
onAcknowledge={onAcknowledge}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /acknowledge/i }));
expect(onAcknowledge).toHaveBeenCalledTimes(1);
unmount();

renderBar({ severity: "S1", acknowledgedBy: undefined }, { isAcknowledging: true });
expect(screen.getByRole("button", { name: /acknowledge/i })).toBeDisabled();
});

it("also disables Acknowledge while a different lifecycle action's patchCase mutation is in flight", () => {
// Guards against the two actions sharing one mutation's isPending flag:
// a lifecycle transition (e.g. Assign to me) in flight must not leave
// Acknowledge clickable and racing it.
render(
<CaseActionBar
caseDetail={{ ...BASE_CASE, severity: "S1", acknowledgedBy: undefined }}
onAction={vi.fn()}
onAcknowledge={vi.fn()}
isAcknowledging={false}
isPending
/>,
);
expect(screen.getByRole("button", { name: /acknowledge/i })).toBeDisabled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
ChevronDown,
Clock,
Copy,
Eye,
Gauge,
GitBranch,
Inbox,
Expand All @@ -46,7 +47,7 @@ import type {
CaseLifecycleAction,
CsmCaseDetail,
} from "@features/csm-cases/types/csmCases";
import type { CaseState } from "@features/csm-dashboard/types/abtDashboard";
import type { CaseState, Severity } from "@features/csm-dashboard/types/abtDashboard";
import { stateLabel } from "@features/csm-dashboard/utils/abtDashboard";

/**
Expand Down Expand Up @@ -387,6 +388,34 @@ interface CaseActionBarProps {
* so a click has visible feedback even before the resulting toast/state
* change lands. */
isPending?: boolean;
/**
* Acknowledge the case as the signed-in engineer. When omitted, the
* acknowledge button is never rendered — so a caller that has no acknowledge
* mutation wired up cannot show a dead button.
*/
onAcknowledge?: () => void | Promise<unknown>;
/** True while the acknowledge PATCH is in flight. */
isAcknowledging?: boolean;
}

/**
* Severities whose cases are worth acknowledging. S4 is excluded deliberately:
* it mirrors which cases the out-of-band acknowledgement notifications are
* raised for, so the button appears on exactly the cases an engineer could
* already have acknowledged from a notification, and on no others.
*/
const ACKNOWLEDGEABLE_SEVERITIES = new Set<Severity>(["S0", "S1", "S2", "S3"]);

/**
* Whether the acknowledge action applies to this case: nobody has claimed it
* yet and it is severe enough to be worth claiming. Acknowledgement is
* first-write-wins, so once `acknowledgedBy` is set there is nothing left to
* do and the button disappears rather than turning into a no-op.
*/
function canAcknowledge(caseDetail: CsmCaseDetail): boolean {
return (
!caseDetail.acknowledgedBy && ACKNOWLEDGEABLE_SEVERITIES.has(caseDetail.severity)
);
}

/**
Expand All @@ -401,6 +430,8 @@ export default function CaseActionBar({
onAction,
closeBlockedReason,
isPending = false,
onAcknowledge,
isAcknowledging = false,
}: CaseActionBarProps): JSX.Element {
const [menuAnchor, setMenuAnchor] = useState<HTMLElement | null>(null);
const [stateMenuAnchor, setStateMenuAnchor] = useState<HTMLElement | null>(null);
Expand Down Expand Up @@ -431,6 +462,27 @@ export default function CaseActionBar({
justifyContent: { xs: "flex-start", md: "flex-end" },
}}
>
{!!onAcknowledge && canAcknowledge(caseDetail) && (
// Sits to the left of the state control and stays outlined: claiming a
// case is a lighter act than moving it through its lifecycle, so it must
// not out-shout the primary transition.
<Button
size="small"
variant="outlined"
color="primary"
startIcon={
isAcknowledging ? (
<CircularProgress size={14} color="inherit" />
) : (
<Eye size={16} />
)
}
disabled={isAcknowledging || isPending}
onClick={() => void onAcknowledge()}
>
Acknowledge
</Button>
)}
{primary.length === 1 && (
// A single reachable state needs no menu — show the transition
// itself as one click rather than "Change state" → pick the only item.
Expand Down
Loading