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
2 changes: 1 addition & 1 deletion apps/csm-portal/backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ backend/
│ │ └── security_headers.go # X-Content-Type-Options, CSP, HSTS on every response
│ └── handler/
│ ├── cases.go # HTTP handlers for case endpoints
│ ├── state.go # Case state machine (nextStates, isValidStateTransition)
│ ├── state.go # Case state machine (nextStates, isValidStateTransition, canCreateRelatedCase)
│ ├── catalogs.go # HTTP handlers for catalog endpoints (ServiceNow only)
│ ├── change_requests.go # HTTP handlers for change-request endpoints
│ ├── product_vulnerabilities.go # HTTP handlers for product vulnerability endpoints (ServiceNow only)
Expand Down
55 changes: 54 additions & 1 deletion apps/csm-portal/backend/internal/handler/cases_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"net/http/httptest"
"strings"
"testing"
"time"

"github.com/wso2-open-operations/cs-tools/apps/csm-portal/backend/internal/apierror"
)
Expand Down Expand Up @@ -1051,7 +1052,7 @@ func TestGetCase(t *testing.T) {
{caseStateWaitingOnWSO2, []string{caseStateWorkInProgress}},
{caseStateAwaitingInfo, []string{caseStateWaitingOnWSO2}},
{caseStateSolutionProposed, []string{caseStateClosed, caseStateWaitingOnWSO2}},
{caseStateClosed, []string{caseStateReopened}},
{caseStateClosed, []string{}},
{caseStateReopened, []string{caseStateWorkInProgress}},
}
for _, tc := range cases {
Expand Down Expand Up @@ -1081,6 +1082,58 @@ func TestGetCase(t *testing.T) {
}
})

t.Run("nextStates surfaces reopened as the create-related-case signal", func(t *testing.T) {
type getCaseNextStatesResp struct {
NextStates []string `json:"nextStates"`
}
recentClosed := time.Now().Add(-10 * 24 * time.Hour).Format(time.RFC3339)
oldClosed := time.Now().Add(-90 * 24 * time.Hour).Format(time.RFC3339)
recentClosedNoZone := time.Now().Add(-10 * 24 * time.Hour).UTC().Format("2006-01-02 15:04:05")
cases := []struct {
name string
body string
wantNext []string
}{
{"closed case within the 60-day window", `{"id":"` + testCaseID + `","type":"case","state":"closed","closedOn":"` + recentClosed + `"}`, []string{caseStateReopened}},
{"closed case outside the 60-day window", `{"id":"` + testCaseID + `","type":"case","state":"closed","closedOn":"` + oldClosed + `"}`, []string{}},
{"closed case with a zoneless space-separated closedOn", `{"id":"` + testCaseID + `","type":"case","state":"closed","closedOn":"` + recentClosedNoZone + `"}`, []string{caseStateReopened}},
{"closed case with no closedOn or updatedOn", `{"id":"` + testCaseID + `","type":"case","state":"closed"}`, []string{}},
{"open case with a closedOn value", `{"id":"` + testCaseID + `","type":"case","state":"open","closedOn":"` + recentClosed + `"}`, []string{caseStateWorkInProgress}},
{"closed service_request within the window", `{"id":"` + testCaseID + `","type":"service_request","state":"closed","closedOn":"` + recentClosed + `"}`, []string{}},
{"closed case with no type set", `{"id":"` + testCaseID + `","state":"closed","closedOn":"` + recentClosed + `"}`, []string{}},
// ServiceNow-backed cases never populate closedOn today, so
// updatedOn stands in for it.
{"closed case with no closedOn, falls back to a recent updatedOn", `{"id":"` + testCaseID + `","type":"case","state":"closed","updatedOn":"` + recentClosed + `"}`, []string{caseStateReopened}},
{"closed case with no closedOn, falls back to an old updatedOn", `{"id":"` + testCaseID + `","type":"case","state":"closed","updatedOn":"` + oldClosed + `"}`, []string{}},
{"closed case prefers closedOn over updatedOn when both present", `{"id":"` + testCaseID + `","type":"case","state":"closed","closedOn":"` + oldClosed + `","updatedOn":"` + recentClosed + `"}`, []string{}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
client := &mockEntityCaseClient{
getCaseFn: func(_ context.Context, _ string) ([]byte, error) {
return []byte(tc.body), nil
},
}
h := NewCaseHandler(client)
r := withUser(httptest.NewRequest(http.MethodGet, "/cases/"+testCaseID, nil))
r.SetPathValue("id", testCaseID)
w := httptest.NewRecorder()
h.GetCase(w, r)
assertStatus(t, w, http.StatusOK)
resp := decodeJSON[getCaseNextStatesResp](t, w)
if len(resp.NextStates) != len(tc.wantNext) {
t.Fatalf("nextStates = %v, want %v", resp.NextStates, tc.wantNext)
}
for i, got := range resp.NextStates {
if got != tc.wantNext[i] {
t.Errorf("nextStates[%d] = %v, want %v", i, got, tc.wantNext[i])
}
}
})
}
})

t.Run("upstream errors are mapped correctly", func(t *testing.T) {
for _, tc := range upstreamErrors("Failed to retrieve case details.") {
t.Run(tc.name, func(t *testing.T) {
Expand Down
114 changes: 106 additions & 8 deletions apps/csm-portal/backend/internal/handler/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,21 @@

package handler

import "encoding/json"
import (
"encoding/json"
"time"
)

// relatedCaseWindow mirrors the upstream data source's own eligibility rule:
// a new case may only be linked as related to a case that was closed within
// this window.
const relatedCaseWindow = 60 * 24 * time.Hour

// caseTypeCase is the standard support case type — as opposed to
// service_request, security_report_analysis, or engagement. Related-case
// creation is only offered for this type; the other types have their own
// create flows that don't accept a relatedCaseId today.
const caseTypeCase = "case"

const (
caseStateOpen = "open"
Expand All @@ -30,7 +44,12 @@ const (

// nextStates returns the valid next states reachable from the given case state.
// Role-gated transitions (team-lead override, auto-close) are excluded until
// role enforcement is implemented.
// role enforcement is implemented. Closed is terminal — the upstream data
// source rejects any outbound transition from a closed case, so no next
// states are advertised for it here. The one exception is surfaced by the
// caller (injectNextStates): `caseStateReopened` is reused as a signal that
// this closed case is still within its related-case window, since the
// frontend has no real reopen to offer, only "create a related case".
func nextStates(state string) []string {
switch state {
case caseStateOpen:
Expand All @@ -44,7 +63,7 @@ func nextStates(state string) []string {
case caseStateSolutionProposed:
return []string{caseStateClosed, caseStateWaitingOnWSO2}
case caseStateClosed:
return []string{caseStateReopened}
return []string{}
case caseStateReopened:
return []string{caseStateWorkInProgress}
default: // unknown values are terminal
Expand All @@ -63,9 +82,68 @@ func isValidStateTransition(from, to string) bool {
return false
}

// injectNextStates parses raw case JSON returned by the entity service, derives
// the valid next states from the "state" field, and returns the JSON with a
// "nextStates" key appended.
// closedOnLayouts are the timestamp shapes the "closedOn" field has been seen
// in, in order of preference. The entity service normally emits RFC 3339, but
// some upstream (ServiceNow) values pass through as bare "YYYY-MM-DD HH:MM:SS"
// with no zone — the frontend's normalizeBackendTimestamp (src/utils/dateTime.ts)
// treats that shape as UTC, so this mirrors it rather than only accepting
// RFC 3339 and silently hiding the action for those cases.
var closedOnLayouts = []string{
time.RFC3339,
"2006-01-02 15:04:05",
}

// parseClosedOn parses "closedOn" using the first matching layout in
// closedOnLayouts, treating a zoneless value as UTC.
func parseClosedOn(closedOn string) (time.Time, bool) {
if t, err := time.Parse(time.RFC3339, closedOn); err == nil {
return t, true
}
for _, layout := range closedOnLayouts[1:] {
if t, err := time.ParseInLocation(layout, closedOn, time.UTC); err == nil {
return t, true
}
}
return time.Time{}, false
}

// canCreateRelatedCase reports whether a new case may be created as related to
// a case of the given type/state, closed at closedOn (as returned in the
// "closedOn" field — see parseClosedOn for accepted shapes). The ServiceNow
// data source does not populate "closedOn" at all today, so this falls back
// to updatedOn (also raw case JSON, always present) when closedOn is absent —
// a closed case is normally terminal, so its last update time is a reasonable
// stand-in for its close time. This is a best-effort UI signal only: the
// upstream data source's own eligibility check (closed + within
// relatedCaseWindow) still runs server-side when the related case is
// actually created, so a stale fallback here just fails cleanly there rather
// than corrupting anything. Scoped to caseType == "case" — the other case
// types' create flows have no related-case field yet.
func canCreateRelatedCase(caseType, state, closedOn, updatedOn string) bool {
if caseType != caseTypeCase || state != caseStateClosed {
return false
}
ts := closedOn
if ts == "" {
ts = updatedOn
}
if ts == "" {
return false
}
t, ok := parseClosedOn(ts)
if !ok {
return false
}
return time.Since(t) <= relatedCaseWindow
}

// injectNextStates parses raw case JSON returned by the entity service,
// derives the valid next states from "state", and returns the JSON with a
// "nextStates" key appended. For a closed case still within its related-case
// window (see canCreateRelatedCase), "reopened" is included as the sole
// entry — there is no separate eligibility field; the frontend renders that
// entry as "Create related case" rather than an actual reopen, since a real
// reopen is never valid.
func injectNextStates(data []byte) ([]byte, error) {
var m map[string]json.RawMessage
if err := json.Unmarshal(data, &m); err != nil {
Expand All @@ -77,10 +155,30 @@ func injectNextStates(data []byte) ([]byte, error) {
return nil, err
}
}
ns, err := json.Marshal(nextStates(state))
var caseType string
if raw, ok := m["type"]; ok {
// Best-effort: a null/absent type just leaves it empty, which
// canCreateRelatedCase treats as ineligible.
_ = json.Unmarshal(raw, &caseType)
}
var closedOn string
if raw, ok := m["closedOn"]; ok {
// Best-effort: a null or malformed value just leaves closedOn empty,
// so canCreateRelatedCase falls back to updatedOn.
_ = json.Unmarshal(raw, &closedOn)
}
var updatedOn string
if raw, ok := m["updatedOn"]; ok {
_ = json.Unmarshal(raw, &updatedOn)
}
ns := nextStates(state)
if canCreateRelatedCase(caseType, state, closedOn, updatedOn) {
ns = []string{caseStateReopened}
}
nsJSON, err := json.Marshal(ns)
if err != nil {
return nil, err
}
m["nextStates"] = ns
m["nextStates"] = nsJSON
return json.Marshal(m)
}
7 changes: 7 additions & 0 deletions apps/csm-portal/backend/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3495,6 +3495,13 @@ components:
- reopened
- solution_proposed
- closed
description: >-
Valid next states from the current one. A closed case can never
actually be reopened (the backing data source has no such
transition); `reopened` appears here only as a signal that a new
case may still be created as related to this one — true for a
standard `case`-type case closed within the last 60 days, empty
otherwise.

CaseSearchView:
type: object
Expand Down
24 changes: 22 additions & 2 deletions apps/csm-portal/webapp/src/api/backend/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ export interface BeCase {
state?: BeCaseState;
createdAt?: string;
updatedAt?: string;
closedAt?: string;
closedOn?: string;
}

/** A referenced user, as embedded in case views (not just an id string). */
Expand All @@ -122,6 +122,12 @@ export interface BeEntityRef {
name: string;
}

/** A referenced case carrying only its display number, e.g. the related case. */
export interface BeCaseNumberRef {
id: string;
number?: string;
}

/**
* The assigned CS engineer embedded in case views. Carries `email` so the FE
* can tell whether the case is assigned to the signed-in user (the only stable
Expand Down Expand Up @@ -173,7 +179,15 @@ export interface BeCaseView {
state?: BeCaseState;
/** Work sub-state; only meaningful while `state` is `work_in_progress`. */
workState?: BeCaseWorkState | null;
/**
* States this case may transition into next. For a closed case, `reopened`
* appearing here is not a real reopen (the data source has no such
* transition) — it signals that a new case may still be created as related
* to this one, within its 60-day window.
*/
nextStates?: BeCaseState[];
/** The case this one was created as related to, when any. */
relatedCase?: BeCaseNumberRef | null;
createdBy?: BeUserRef;
/** The CS engineer the case is assigned to; null when unassigned. */
assignedEngineer?: BeAssignedEngineerRef | null;
Expand All @@ -196,7 +210,7 @@ export interface BeCaseView {
conversation?: BeEntityRef | null;
createdOn?: string;
updatedOn?: string;
closedAt?: string | null;
closedOn?: string | null;
}

export interface BeCaseCreatePayload {
Expand All @@ -209,6 +223,12 @@ export interface BeCaseCreatePayload {
description: string;
severity: BeCaseSeverity;
issueType: BeCaseIssueType;
/**
* UUID of the closed case this one is related to. The data source only
* accepts this for a case closed within the last 60 days — otherwise it
* rejects the create with a "related case too old" error.
*/
relatedCaseId?: string;
/** Optional supporting files (raw base64), like the customer portal. */
attachments?: BeCaseAttachmentPayload[];
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ function detailFromBeCase(
state: uiStateFromBe(c.state),
workState: c.workState ?? null,
nextStates: (c.nextStates ?? []).map(uiStateFromBe),
relatedCase: c.relatedCase
? { id: c.relatedCase.id, caseNumber: c.relatedCase.number }
: undefined,
assignee,
assigneeIsMe,
slaClockType: "ack",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,45 @@ describe("CaseActionBar — reassign gating for WIP-Ongoing", () => {
});
});

describe("CaseActionBar — create related case (closed-case reopen replacement)", () => {
it("is not offered on a closed case the backend has not flagged eligible (empty nextStates)", () => {
render(
<CaseActionBar caseDetail={caseInState("closed", [])} onAction={() => {}} />,
);
expect(
screen.queryByRole("button", { name: /create related case/i }),
).not.toBeInTheDocument();
});

it("renders as a single primary button when the backend flags the case eligible", () => {
const onAction = vi.fn();
render(
<CaseActionBar
caseDetail={caseInState("closed", ["reopened"])}
onAction={onAction}
/>,
);
const button = screen.getByRole("button", { name: /create related case/i });
expect(button).toBeInTheDocument();
// Must dispatch the create_related_case action, never a real "reopened"
// state PATCH — the data source has no such transition.
fireEvent.click(button);
expect(onAction).toHaveBeenCalledWith("create_related_case", "reopened");
});

it("never renders a literal 'Reopened' state-transition button", () => {
// Guards against regressing to the pre-fix behavior where a closed case's
// stray `reopened` nextState rendered as a generic (broken) reopen action.
render(
<CaseActionBar
caseDetail={caseInState("closed", ["reopened"])}
onAction={() => {}}
/>,
);
expect(screen.queryByRole("button", { name: /^reopened$/i })).not.toBeInTheDocument();
});
});

describe("CaseActionBar — unbuilt roadmap items stay disabled, not silently mock", () => {
// Create incident / Link to incident / Create task / Hold auto-closure have
// no backend flow yet. They must never be clickable — a click that reaches
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,15 @@ const TARGET_CONFIG: Partial<Record<CaseState, TargetConfig>> = {
icon: <CheckCircle size={16} />,
confirm: CLOSE_CONFIRM,
},
// Not a real reopen — the data source has no transition out of closed. The
// backend only puts `reopened` in a closed case's `nextStates` as a signal
// that a related case may still be created (see that field's doc); `action`
// routes to the create-related-case flow in `onAction` instead of a PATCH.
reopened: {
action: "create_related_case",
color: "primary",
icon: <LinkIcon size={16} />,
},
};

/**
Expand All @@ -147,6 +156,7 @@ const TRANSITION_LABEL: Partial<Record<CaseState, string>> = {
awaiting_info: "Request information",
waiting_on_wso2: "Wait on WSO2",
closed: "Close",
reopened: "Create related case",
};

/** Build the button for a transition into `target`, labelled by the BE state. */
Expand Down
Loading