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
+2 -2
View File
@@ -4,7 +4,7 @@ import { connect } from "react-redux";
import useTranslation from "next-translate/useTranslation";
import { formatDates } from "../utils";
import { useState } from "react";
import { getStagesForAppealType } from "./summary/utils/caseStagesByAppealType";
import { getLifecycleStagesForCase } from "../../lib/domain/case-lifecycle";
const statusClassMap = {
complete: "govuk-tag govuk-!-margin-top-2",
@@ -29,7 +29,7 @@ const StatusDetails = (props) => {
console.log(props.currentView?.caseReference?.statuscode);
const stages = getStagesForAppealType(
const stages = getLifecycleStagesForCase(
props.currentView?.caseReference?.appealType,
props.currentView?.caseReference?.statuscode,
specialistProcess
@@ -1,3 +1,8 @@
import { getStageCaseTypeKey } from "../../../../lib/domain/case-lifecycle/getStageCaseTypeKey";
import { getStagesForCaseTypeKey } from "../../../../lib/domain/case-lifecycle/getStagesForCaseTypeKey";
import { getLifecycleStageIndex } from "../../../../lib/domain/case-lifecycle/getLifecycleStageIndex";
import { getLifecycleStageStatus } from "../../../../lib/domain/case-lifecycle/getLifecycleStageStatus";
const slugify = (value) =>
value
.toString()
@@ -771,106 +776,10 @@ export const stagesByCaseType = {
])
};
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"
};
const normaliseCaseType = (caseType) =>
caseType
?.toString()
.trim()
.toUpperCase()
.replace(/&/g, "AND")
.replace(/[^A-Z0-9]+/g, "_")
.replace(/^_|_$/g, "");
const specialistProcessKeyById = {
// 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"
};
const appealTypeSpecialistProcessStageMap = {
846040015: specialistProcessKeyById
};
const resolveStages = (caseTypeOrAppealTypeId, specialistProcess) => {
const appealTypeId = Number(caseTypeOrAppealTypeId);
const key = getStageCaseTypeKey(caseTypeOrAppealTypeId, specialistProcess);
const specialistProcessKey =
appealTypeSpecialistProcessStageMap[appealTypeId]?.[specialistProcess];
const appealTypeKey =
specialistProcessKey || caseTypeKeyByAppealTypeId[appealTypeId];
const normalised = normaliseCaseType(caseTypeOrAppealTypeId);
const key = appealTypeKey || caseTypeAliases[normalised] || normalised;
const entry = stagesByCaseType[key];
if (!entry) return [];
if (typeof entry === "string") {
return stagesByCaseType[entry] || [];
}
return entry;
};
const caseClosedStatusIds = [1000, 5, 6, 846040013, 846040060, 846040059];
const isCaseClosedStatus = (stageId) =>
caseClosedStatusIds.includes(Number(stageId));
const getStageIndex = (stages, currentStageNumber) => {
const exactIndex = stages.findIndex(
(stage) => Number(stage.stageId) === currentStageNumber
);
if (exactIndex !== -1) return exactIndex;
if (isCaseClosedStatus(currentStageNumber)) {
return stages.findIndex((stage) => stage.titleKey === "case-closed");
}
return -1;
return getStagesForCaseTypeKey(key);
};
export const getStagesForAppealType = (
@@ -879,46 +788,10 @@ export const getStagesForAppealType = (
specialistProcess
) => {
const stages = resolveStages(caseType, specialistProcess);
const currentStageNumber = Number(currentStageId);
const currentIndex = getStageIndex(stages, currentStageNumber);
const currentIndex = getLifecycleStageIndex(stages, currentStageId);
return stages.map((stage, index) => ({
...stage,
status:
currentIndex === -1
? "not-started"
: index < currentIndex
? "complete"
: index === currentIndex
? "in-progress"
: "not-started"
status: getLifecycleStageStatus(index, currentIndex)
}));
};
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"
};