Merged PR 2374: addeding domain layer extrraction

# Summary

This PR introduces a **Case Lifecycle Domain Boundary** to centralize lifecycle decision logic and reduce coupling within the appeals application.

The work is **behaviour-preserving** and introduces no intentional changes to business rules, CRM integrations, translations, dashboards, API routes, or user-facing functionality.

## What was added

New lifecycle boundary:

```text
lib/domain/case-lifecycle/
```

Key responsibilities extracted:

- Specialist process normalization
- Appeal type mapping
- Specialist process stage override mapping
- Stage case-type resolution
- Stage catalogue lookup
- Closed-case status recognition
- Lifecycle stage index resolution
- Lifecycle stage status assignment

## Behaviour preserved

Characterization tests were added before each extraction to preserve:

- Appeal type mapping and aliases
- Specialist process handling
- Lifecycle stage progression
- Closed-case handling
- Status assignment (`complete`, `in-progress`, `not-started`)
- Existing ROW behaviour
- Existing `statuscode` lifecycle semantics

Closed-case recognition remains unchanged for:

```text
1000
5
6
846040013
846040059
846040060
```

## Documentation

Added:

```text
lib/domain/case-lifecycle/README.md
```

Documenting:

- Boundary ownership
- Non-goals
- Lifecycle invariants
- Known architectural constraints
- Future extraction roadmap

## Testing

Added lifecycle characterization coverage for:

- Stage wrapper behaviour
- Specialist process normalization
- Appeal type mapping
- Specialist process stage mapping
- Stage case-type resolution
- Stage catalogue lookup
- Progress behaviour
- Closed-case status handling
- Stage index resolution
- Stage status assignment

## Validation

- Lifecycle characterization tests passed
- `npm run lint` passed with no errors

## Out of Scope

No changes to:

- Stage catalogue ownership
- Representation eligibility
- Dashboard calculations
- CRM/OData queries
- API routes
- Redux state
- EN/CY translations
- Event visibility logic

## Risk

**Low risk**

The refactor was delivered through small, characterization-first slices with no functional changes intended.

Related work items: #23527
This commit is contained in:
Robert Bond
2026-06-08 09:34:30 +00:00
parent 2262f3ab74
commit 9595ad86df
26 changed files with 4202 additions and 165 deletions
+150
View File
@@ -0,0 +1,150 @@
# Case Lifecycle Domain Boundary
## Purpose
This directory contains a narrow **case lifecycle decision boundary**, not a full domain model.
Its current purpose is to centralise **behaviour-preserving interpretation logic** that was previously embedded in UI-adjacent helpers, while keeping live behaviour unchanged during refactor.
This boundary should be treated as an incremental extraction seam for read-only lifecycle interpretation, not as a place to redesign lifecycle behaviour.
## Current ownership
### `getLifecycleStagesForCase(...)`
- thin wrapper around the existing stage/progress helper
- preserves existing output shape for current consumers
- does not define new lifecycle semantics
### `normalizeSpecialistProcess(...)`
- raw CRM field fallback only
- preserves current canonical and misspelled field fallback behaviour
- does not validate or reinterpret CRM values
### `mapAppealType(...)`
- current appeal-type-to-case-type mapping
- current alias handling
- current unknown input behaviour
### `mapSpecialistProcessStageType(...)`
- specialist-process stage override mapping
- preserves current placeholder-like CRM specialist process values
- preserves current override target keys and fallback behaviour
### `getStageCaseTypeKey(...)`
- current stage case-type resolution decision
- combines:
- appeal type mapping
- specialist-process stage override mapping
- alias normalization
- current fallback behaviour
### `getStagesForCaseTypeKey(...)`
- current stage catalogue lookup only
- preserves:
- direct key lookup
- string indirection resolution
- fallback to `[]`
- does not own stage catalogue contents
## Stage catalogue ownership and lookup
Stage catalogue arrays still live in:
- `components/case/summary/utils/caseStagesByAppealType.js`
The domain boundary currently owns **lookup behaviour only**:
- direct key lookup
- string indirection resolution
- fallback to `[]`
`getStagesForCaseTypeKey(...)` must not be treated as stage catalogue ownership yet.
Lazy access in `getStagesForCaseTypeKey(...)` is intentional:
- it avoids circular dependency issues while stage arrays remain in the legacy helper
- the domain helper must not eagerly import catalogue contents while ownership remains outside the domain boundary
Any future catalogue migration must be a separate, explicitly scoped slice.
Until catalogue ownership moves:
- stage array contents remain the source of truth in the legacy helper
- the domain lookup helper must preserve reference identity
- the domain lookup helper must preserve current fallback behaviour
## Explicit non-goals
This boundary does **not** currently own:
- stage catalogue arrays
- progress/status calculation
- closed-case handling
- representation eligibility
- dashboard layout or calculations
- translations / EN-CY labels
- CRM/OData query construction
- API routes
- Redux state shape
- upload, submit, finalisation, or notification logic
## Behaviour-preservation invariants
Future slices must preserve:
- current stage IDs
- translation keys
- alias behaviour
- unknown input behaviour
- current fallback semantics
- current specialist-process override semantics
- status-vs-stage ambiguity
- current closed-case behaviour
## Known oddities to preserve
1. `statuscode` is currently passed into stage/progress logic.
- Do not rename or reinterpret this without a separate behaviour-change decision.
2. Specialist-process overrides are intentionally narrow.
- They currently apply only through the existing appeal-type-based resolution path.
- Do not broaden this behaviour during refactor.
3. Some specialist process values appear placeholder-like.
- Do not “correct” them without CRM/product confirmation.
4. Some ROW inputs currently produce non-intuitive stage output.
- Preserve characterized behaviour unless explicitly changing business rules.
5. The test harnesses are VM-based and import-sensitive.
- Future extractions must update harness injections carefully and avoid broad rewrites.
## Testing expectations
Future slices should run:
```bash
node tests/phase22/case-lifecycle-stage-case-type-resolution.test.cjs
node tests/phase22/case-lifecycle-specialist-process-stage-mapping.test.cjs
node tests/phase22/case-lifecycle-appeal-type-mapping.test.cjs
node tests/phase22/case-lifecycle-specialist-process-normalization.test.cjs
node tests/phase22/case-lifecycle-stage-wrapper.test.cjs
node tests/phase22/representation-build-reps-arr-rules.test.cjs
npm run lint
```
## Future extraction order
Recommended future order:
1. stage catalogue lookup helper
2. stage catalogue ownership only after enough characterization coverage
3. progress/status calculation extraction
4. additional read-only consumers
5. representation eligibility only as a separate policy slice after characterization
@@ -0,0 +1,17 @@
import { isClosedCaseStatus } from "./isClosedCaseStatus";
export function getLifecycleStageIndex(stages, currentStageId) {
const currentStageNumber = Number(currentStageId);
const exactIndex = stages.findIndex(
(stage) => Number(stage.stageId) === currentStageNumber
);
if (exactIndex !== -1) return exactIndex;
if (isClosedCaseStatus(currentStageNumber)) {
return stages.findIndex((stage) => stage.titleKey === "case-closed");
}
return -1;
}
@@ -0,0 +1,9 @@
export function getLifecycleStageStatus(index, currentIndex) {
return currentIndex === -1
? "not-started"
: index < currentIndex
? "complete"
: index === currentIndex
? "in-progress"
: "not-started";
}
@@ -0,0 +1,9 @@
import { getStagesForAppealType } from "../../../components/case/summary/utils/caseStagesByAppealType";
export function getLifecycleStagesForCase(
caseType,
currentStageId,
specialistProcess
) {
return getStagesForAppealType(caseType, currentStageId, specialistProcess);
}
@@ -0,0 +1,26 @@
import {
caseTypeAliases,
caseTypeKeyByAppealTypeId,
mapAppealType,
normaliseCaseType
} from "./mapAppealType";
import { mapSpecialistProcessStageType } from "./mapSpecialistProcessStageType";
export function getStageCaseTypeKey(caseTypeOrAppealTypeId, specialistProcess) {
const appealTypeId = Number(caseTypeOrAppealTypeId);
const mappedCaseType = mapAppealType(caseTypeOrAppealTypeId);
const specialistProcessKey =
caseTypeKeyByAppealTypeId[appealTypeId] === mappedCaseType
? mapSpecialistProcessStageType(mappedCaseType, specialistProcess)
: undefined;
const normalised = normaliseCaseType(caseTypeOrAppealTypeId);
return (
specialistProcessKey ||
mappedCaseType ||
caseTypeAliases[normalised] ||
normalised
);
}
@@ -0,0 +1,16 @@
const getStagesByCaseType = () =>
require("../../../components/case/summary/utils/caseStagesByAppealType")
.stagesByCaseType;
export function getStagesForCaseTypeKey(stageCaseTypeKey) {
const stagesByCaseType = getStagesByCaseType();
const entry = stagesByCaseType[stageCaseTypeKey];
if (!entry) return [];
if (typeof entry === "string") {
return stagesByCaseType[entry] || [];
}
return entry;
}
+9
View File
@@ -0,0 +1,9 @@
export { getLifecycleStagesForCase } from "./getLifecycleStagesForCase";
export { isClosedCaseStatus } from "./isClosedCaseStatus";
export { getLifecycleStageIndex } from "./getLifecycleStageIndex";
export { getLifecycleStageStatus } from "./getLifecycleStageStatus";
export { getStageCaseTypeKey } from "./getStageCaseTypeKey";
export { getStagesForCaseTypeKey } from "./getStagesForCaseTypeKey";
export { mapAppealType } from "./mapAppealType";
export { mapSpecialistProcessStageType } from "./mapSpecialistProcessStageType";
export { normalizeSpecialistProcess } from "./normalizeSpecialistProcess";
@@ -0,0 +1,5 @@
const closedCaseStatusIds = [1000, 5, 6, 846040013, 846040060, 846040059];
export function isClosedCaseStatus(statusOrStageId) {
return closedCaseStatusIds.includes(Number(statusOrStageId));
}
@@ -0,0 +1,81 @@
export const caseTypeAliases = {
// Planning S78 group
S78: "PLANNING_S78",
PLANNING_78: "PLANNING_S78",
PLANNING_S78: "PLANNING_S78",
CONDITIONS_73_79: "CONDITIONS_73_79",
LBCAC: "LBCAC",
LDCS: "LDCS",
PLANNING_OBLIGATIONS_S106: "PLANNING_OBLIGATIONS_S106",
PRIOR_NOTIFICATION: "PRIOR_NOTIFICATION",
// Other types
HAS: "HAS",
CALL_INS: "CALL_INS",
CALL_IN: "CALL_INS",
ENFORCEMENT: "ENFORCEMENT",
ENFORCEMENT_LISTED_BUILDING: "ENFORCEMENT_LISTED_BUILDING",
MAINTENANCE_OF_LAND: "MAINTENANCE_OF_LAND",
RIGHTS_OF_WAY_SCHEDULE_14: "RIGHTS_OF_WAY_SCHEDULE_14",
RIGHTS_OF_WAY_ORDERS: "RIGHTS_OF_WAY_ORDERS",
REQUESTS_FOR_DIRECTION: "REQUESTS_FOR_DIRECTION",
ADVERTS: "ADVERTS",
HEDGEROW_TPO: "HEDGEROW_TPO",
COMMON_LAND: "COMMON_LAND",
COMPULSORY_PURCHASE_ORDERS: "COMPULSORY_PURCHASE_ORDERS",
ELECTRICITY_ACT: "ELECTRICITY_ACT",
HARBOUR_REVISION_ORDER: "HARBOUR_REVISION_ORDER",
TRANSPORT_WORKS: "TRANSPORT_WORKS",
WAYLEAVE: "WAYLEAVE",
NON_VALIDATION: "NON_VALIDATION",
DNS: "DNS",
SIP: "SIP"
};
export const caseTypeKeyByAppealTypeId = {
846040000: "PLANNING_S78",
846040001: "CONDITIONS_73_79",
846040002: "SIP",
846040003: "PLANNING_OBLIGATIONS_S106",
846040004: "HAS",
846040005: "ENFORCEMENT",
846040006: "ENFORCEMENT_LISTED_BUILDING",
846040007: "MAINTENANCE_OF_LAND",
846040008: "LDCS",
846040009: "PLANNING_S78",
846040010: "CALL_INS",
846040011: "DNS",
846040012: "ELECTRICITY_ACT",
846040013: "TRANSPORT_WORKS",
846040014: "HARBOUR_REVISION_ORDER",
846040015: "RIGHTS_OF_WAY_SCHEDULE_14",
846040016: "COMMON_LAND",
846040017: "HEDGEROW_TPO",
846040018: "ADVERTS",
846040019: "COMPULSORY_PURCHASE_ORDERS",
846040020: "PLANNING_S78",
846040021: "WAYLEAVE",
846040024: "NON_VALIDATION",
846040025: "LBCAC"
};
export const normaliseCaseType = (caseType) =>
caseType
?.toString()
.trim()
.toUpperCase()
.replace(/&/g, "AND")
.replace(/[^A-Z0-9]+/g, "_")
.replace(/^_|_$/g, "");
export function mapAppealType(appealTypeId) {
const mappedCaseType = caseTypeKeyByAppealTypeId[Number(appealTypeId)];
if (mappedCaseType) {
return mappedCaseType;
}
const normalised = normaliseCaseType(appealTypeId);
return caseTypeAliases[normalised] || normalised;
}
@@ -0,0 +1,19 @@
export const specialistProcessStageTypeByCaseType = {
RIGHTS_OF_WAY_SCHEDULE_14: {
// TODO: replace these with the real CRM option-set values
846040000: "RIGHTS_OF_WAY_ORDERS",
846040100: "RIGHTS_OF_WAY_ORDERS",
846040001: "RIGHTS_OF_WAY_ORDERS",
846040101: "RIGHTS_OF_WAY_ORDERS",
846040002: "RIGHTS_OF_WAY_SCHEDULE_14",
846040102: "RIGHTS_OF_WAY_SCHEDULE_14",
846040003: "REQUESTS_FOR_DIRECTION",
846040103: "REQUESTS_FOR_DIRECTION"
}
};
export function mapSpecialistProcessStageType(caseTypeKey, specialistProcess) {
return specialistProcessStageTypeByCaseType[caseTypeKey]?.[
specialistProcess
];
}
@@ -0,0 +1,7 @@
export function normalizeSpecialistProcess(source) {
return (
source?.pinswg_specialistcaseprocess ||
source?.pinswg_speacialistcaseprocess ||
""
);
}
+3 -9
View File
@@ -10,6 +10,7 @@ import {
import { getRepsFromBlob } from "../../actions/services/documentService";
import { getBasicSearch } from "../../actions/services/searchService";
import { consoleLogger } from "../../actions/core/logger";
import { normalizeSpecialistProcess } from "../domain/case-lifecycle";
import {
getFormCollectionByID,
getSearchDetails
@@ -280,16 +281,9 @@ export const loadNewRepresentation = async ({ store, ctx, bootstrap }) => {
"currentType": "myRepresentations",
"incidentid": searchResultsObj?.value?.[0]?.incidentid,
"appealType": searchResultsObj?.value?.[0]?.pinswg_appealcasetype,
"specialistProcess":
"specialistProcess": normalizeSpecialistProcess(
searchDetailsObj?.[0]?.value?.[0]
?.pinswg_speacialistcaseprocess != null ||
searchDetailsObj?.[0]?.value?.[0]
?.pinswg_specialistcaseprocess != null
? searchDetailsObj?.[0]?.value?.[0]
.pinswg_speacialistcaseprocess ||
searchDetailsObj?.[0]?.value?.[0]
.pinswg_specialistcaseprocess
: ""
)
})
);