diff --git a/apps/csm-portal/backend/README.md b/apps/csm-portal/backend/README.md index ff331c26c4..159a751cf5 100644 --- a/apps/csm-portal/backend/README.md +++ b/apps/csm-portal/backend/README.md @@ -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) diff --git a/apps/csm-portal/backend/internal/handler/cases_test.go b/apps/csm-portal/backend/internal/handler/cases_test.go index a1d251e137..9be4c970e0 100644 --- a/apps/csm-portal/backend/internal/handler/cases_test.go +++ b/apps/csm-portal/backend/internal/handler/cases_test.go @@ -24,6 +24,7 @@ import ( "net/http/httptest" "strings" "testing" + "time" "github.com/wso2-open-operations/cs-tools/apps/csm-portal/backend/internal/apierror" ) @@ -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 { @@ -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) { diff --git a/apps/csm-portal/backend/internal/handler/state.go b/apps/csm-portal/backend/internal/handler/state.go index 849add28c0..3df57cbf9a 100644 --- a/apps/csm-portal/backend/internal/handler/state.go +++ b/apps/csm-portal/backend/internal/handler/state.go @@ -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" @@ -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: @@ -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 @@ -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 { @@ -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) } diff --git a/apps/csm-portal/backend/openapi.yaml b/apps/csm-portal/backend/openapi.yaml index 898449ec6d..60c9a97707 100644 --- a/apps/csm-portal/backend/openapi.yaml +++ b/apps/csm-portal/backend/openapi.yaml @@ -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 diff --git a/apps/csm-portal/webapp/src/api/backend/types.ts b/apps/csm-portal/webapp/src/api/backend/types.ts index bf9b630564..9f2a2c46f6 100644 --- a/apps/csm-portal/webapp/src/api/backend/types.ts +++ b/apps/csm-portal/webapp/src/api/backend/types.ts @@ -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). */ @@ -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 @@ -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; @@ -196,7 +210,7 @@ export interface BeCaseView { conversation?: BeEntityRef | null; createdOn?: string; updatedOn?: string; - closedAt?: string | null; + closedOn?: string | null; } export interface BeCaseCreatePayload { @@ -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[]; } diff --git a/apps/csm-portal/webapp/src/features/csm-cases/api/useGetCsmCaseDetail.ts b/apps/csm-portal/webapp/src/features/csm-cases/api/useGetCsmCaseDetail.ts index f2d07d5551..741859c466 100644 --- a/apps/csm-portal/webapp/src/features/csm-cases/api/useGetCsmCaseDetail.ts +++ b/apps/csm-portal/webapp/src/features/csm-cases/api/useGetCsmCaseDetail.ts @@ -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", diff --git a/apps/csm-portal/webapp/src/features/csm-cases/components/CaseActionBar.test.tsx b/apps/csm-portal/webapp/src/features/csm-cases/components/CaseActionBar.test.tsx index 3909d2f25e..9d72a36bfa 100644 --- a/apps/csm-portal/webapp/src/features/csm-cases/components/CaseActionBar.test.tsx +++ b/apps/csm-portal/webapp/src/features/csm-cases/components/CaseActionBar.test.tsx @@ -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( + {}} />, + ); + 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( + , + ); + 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( + {}} + />, + ); + 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 diff --git a/apps/csm-portal/webapp/src/features/csm-cases/components/CaseActionBar.tsx b/apps/csm-portal/webapp/src/features/csm-cases/components/CaseActionBar.tsx index 459d54917a..1c6365eaa2 100644 --- a/apps/csm-portal/webapp/src/features/csm-cases/components/CaseActionBar.tsx +++ b/apps/csm-portal/webapp/src/features/csm-cases/components/CaseActionBar.tsx @@ -124,6 +124,15 @@ const TARGET_CONFIG: Partial> = { icon: , 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: , + }, }; /** @@ -147,6 +156,7 @@ const TRANSITION_LABEL: Partial> = { 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. */ diff --git a/apps/csm-portal/webapp/src/features/csm-cases/pages/CsmCaseCreatePage.tsx b/apps/csm-portal/webapp/src/features/csm-cases/pages/CsmCaseCreatePage.tsx index 920eccfe21..5e0761babf 100644 --- a/apps/csm-portal/webapp/src/features/csm-cases/pages/CsmCaseCreatePage.tsx +++ b/apps/csm-portal/webapp/src/features/csm-cases/pages/CsmCaseCreatePage.tsx @@ -90,6 +90,12 @@ export default function CsmCaseCreatePage(): JSX.Element { const [searchParams] = useSearchParams(); const lockedProjectId = searchParams.get("projectId") ?? ""; const isProjectLocked = !!lockedProjectId; + // Set when opened from a closed case's "Create related case" action + // (`/cases/new?relatedCaseId=…&relatedCaseNumber=…`). The backend already + // validated eligibility (closed + within its 60-day window) before offering + // that action, so this page just carries the id through on submit. + const relatedCaseId = searchParams.get("relatedCaseId") ?? undefined; + const relatedCaseNumber = searchParams.get("relatedCaseNumber") ?? undefined; const [projectId, setProjectId] = useState(lockedProjectId); const [deploymentId, setDeploymentId] = useState(""); @@ -213,6 +219,7 @@ export default function CsmCaseCreatePage(): JSX.Element { description, severity: priorityFromSeverity(severity), issueType: issueType, + relatedCaseId, }); // The create endpoint doesn't attach files for standard cases, so upload // them to the new case afterwards. A partial failure still lands the case. @@ -244,9 +251,14 @@ export default function CsmCaseCreatePage(): JSX.Element { > Back to cases - + New case + {relatedCaseId && ( + + Related to {relatedCaseNumber ?? "the closed case"} — its id is carried through automatically. + + )} {hasOptionsError && ( diff --git a/apps/csm-portal/webapp/src/features/csm-cases/pages/CsmCaseDetailPage.tsx b/apps/csm-portal/webapp/src/features/csm-cases/pages/CsmCaseDetailPage.tsx index 6a2f201c97..34b95d0045 100644 --- a/apps/csm-portal/webapp/src/features/csm-cases/pages/CsmCaseDetailPage.tsx +++ b/apps/csm-portal/webapp/src/features/csm-cases/pages/CsmCaseDetailPage.tsx @@ -35,6 +35,7 @@ import { ArrowLeft, Clock, Layers, + Link as LinkIcon, ListChecks, MessageSquarePlus, Paperclip, @@ -132,6 +133,10 @@ const LIFECYCLE_TOAST: Record = { resume_work: "Resumed work on this case.", close: "Case closed.", close_no_response: "Closed (no response received).", + // Unused: intercepted before this map is read (see onAction) since it + // navigates instead of showing a toast. Present only to satisfy the + // exhaustive Record. + create_related_case: "", transition: "Case updated.", }; @@ -156,6 +161,8 @@ const LIFECYCLE_SEVERITY: Record = { resume_work: "info", close: "success", close_no_response: "success", + // Unused — see the matching note in LIFECYCLE_TOAST. + create_related_case: "info", transition: "info", }; @@ -546,6 +553,19 @@ export default function CsmCaseDetailPage(): JSX.Element { return; } + // ISSU-004: the backend puts `reopened` in a closed case's + // `nextStates` only as a signal — there is no real reopen (the data + // source has no such transition). Never PATCH it; open the new-case + // form pre-filled with relatedCaseId instead. Must run before the + // generic `targetState` PATCH below, since `beStateFromUi("reopened")` + // is truthy and would otherwise be sent as a state transition. + if (action === "create_related_case" && data) { + const params = new URLSearchParams({ projectId: data.projectId, relatedCaseId: data.id }); + if (data.caseNumber) params.set("relatedCaseNumber", data.caseNumber); + navigate(`/cases/new?${params.toString()}`); + return; + } + if (targetState === "work_in_progress" && data) { void startWork(LIFECYCLE_TOAST[action], LIFECYCLE_SEVERITY[action]); return; @@ -708,6 +728,7 @@ export default function CsmCaseDetailPage(): JSX.Element { startWork, resolveOngoingConflict, currentUserEmail, + navigate, ], ); @@ -897,6 +918,10 @@ export default function CsmCaseDetailPage(): JSX.Element { } const c = data; + // Narrowed once here so the JSX below can use it without a non-null + // assertion — `c.relatedCase` on its own doesn't stay narrowed across the + // `onClick` closure. + const relatedCase = c.relatedCase; const isClosed = c.state === "closed"; // The backend rejects a customer-visible comment unless the case is // work_in_progress + ongoing. Internal work notes are allowed in any state, @@ -988,6 +1013,17 @@ export default function CsmCaseDetailPage(): JSX.Element { )} + {relatedCase && ( + } + label={`Related: ${relatedCase.caseNumber ?? relatedCase.id}`} + onClick={() => navigate(`/cases/${relatedCase.id}`)} + sx={{ fontWeight: 600 }} + /> + )} {c.state === "work_in_progress" && c.workState && ( = { awaiting_info: paletteColor("cyan", 500, "#06b6d4"), solution_proposed: paletteColor("teal", 500, "#14b8a6"), closed: paletteColor("grey", 500, "#6b7280"), + reopened: paletteColor("purple", 500, "#a855f7"), }; /** diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/types/abtDashboard.ts b/apps/csm-portal/webapp/src/features/csm-dashboard/types/abtDashboard.ts index c6dcad9c39..70c94b4b52 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/types/abtDashboard.ts +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/types/abtDashboard.ts @@ -22,7 +22,14 @@ export type CaseState = | "solution_proposed" | "awaiting_info" | "waiting_on_wso2" - | "closed"; + | "closed" + /** + * Only ever appears as a `nextStates` entry on a closed case, never as a + * case's own `state` — it signals "Create related case" is available, not + * an actual reopen (the data source has no such transition). See + * `CsmCaseDetail.nextStates` and `CaseActionBar`'s `reopened` handling. + */ + | "reopened"; /** * Work sub-state of a `work_in_progress` case. `null` / absent when the case is diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/utils/abtDashboard.ts b/apps/csm-portal/webapp/src/features/csm-dashboard/utils/abtDashboard.ts index af0cdedf5d..66b5f90409 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/utils/abtDashboard.ts +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/utils/abtDashboard.ts @@ -46,6 +46,7 @@ export const STATE_LABEL: Record = { awaiting_info: "Awaiting info", waiting_on_wso2: "Waiting on WSO2", closed: "Closed", + reopened: "Reopened", }; // Status chip colour by "whose move is it", so colour carries information when @@ -69,6 +70,12 @@ export const STATE_COLOR: Record< solution_proposed: "default", awaiting_info: "default", closed: "success", + // Defensive: `reopened` only appears in a closed case's `nextStates` (the + // "Create related case" signal, see CaseState's doc) — never as a case's + // own state, so this entry should be unreachable via a real case's state. + // Kept in the "info" bucket rather than omitted, so the exhaustive Record + // still compiles and any incidental rendering doesn't look broken. + reopened: "info", }; /**