Merged PR 2394: adjusting reps domain logic

Related work items: #23754
This commit is contained in:
Robert Bond
2026-06-17 10:55:32 +00:00
parent ca244e5de1
commit 0b66a0e1d4
4 changed files with 421 additions and 1 deletions
@@ -0,0 +1,145 @@
# Representation Type Policy Boundary
## Purpose
This directory contains a narrow **representation type policy boundary**.
It currently owns only the behaviour-preserving rule engine that converts an
already prepared policy context into the current ordered array of available
representation type strings.
It is **not** a full representation domain model.
This boundary should be treated as a small, compatibility-preserving extraction
seam for type-availability decisions, not as a place to redesign the
representation journey.
---
## Current ownership
### `getAvailableRepresentationTypes(context)`
Purpose:
```text
prepared context
ordered representation-type string array
```
The helper currently returns the live consumer contract as an ordered array of
strings, including values such as:
- `Questionnaire`
- `Statement`
- `Final comments`
- `Consultation Response`
- `Local Impact Report`
- `Marine Impact Report`
---
## Explicit contract
Current behaviour intentionally preserves:
- exact string values
- exact ordering
- deduplication behaviour
- current DNS behaviour
- current SIPS behaviour
- current Householder behaviour
- current Advert behaviour
- current CPO behaviour
- current specialist-process behaviour
- current LPA / non-LPA behaviour
- current NRW behaviour
This is a **consumer-facing compatibility contract**.
Changing any of the following would currently be a contract change, not just an
internal refactor:
- returned string values
- returned ordering
- returned deduplication semantics
---
## Compatibility requirements
The following date fields are compatibility-sensitive and must be preserved
carefully:
- `pinswg_startdate`
- `pinswg_startdates`
- `pinswg_statementduedate`
- `pinswg_statementsduedate`
- `pinswg_applicationacceptedasvalid`
These fallbacks are characterized behaviour and must not be casually removed.
---
## Explicit non-goals
This boundary does **not** currently own:
- capacity ownership
- capacity selection UX
- `buildRepresentationContext(...)`
- translation mapping
- Welsh labels
- file naming
- upload logic
- submission logic
- finalisation logic
- questionnaire routing
- Redux state
- API routes
- CRM queries
- dashboard behaviour
---
## Adapter boundary
`buildRepresentationContext(...)` intentionally remains outside this domain
boundary.
It should currently be treated as an **adapter layer**:
```text
CRM / UI state
policy context
```
It is not currently a domain helper.
---
## Future evolution
Any future migration from English string values to:
- stable IDs
- enums
- keys
would be a **contract change**, not merely refactoring.
That kind of migration must be explicitly scoped and characterized before any
implementation.
---
## Testing expectations
Current minimum safety net for this boundary:
```bash
node tests/phase22/representation-build-reps-arr-rules.test.cjs
npm run lint
```
@@ -0,0 +1,147 @@
const APPEAL_TYPES = {
SIPS: 846040002,
DNS: 846040011,
HOUSEHOLDER: 846040004,
ADVERT: 846040018,
EXCLUDE_QUESTIONNAIRE_1: 846040019,
EXCLUDE_QUESTIONNAIRE_2: 846040015,
EXCLUDE_QUESTIONNAIRE_3: 846040016
};
const SPECIALIST_PROCESS = {
WRITTEN_REPS: 846040000,
HEARING: 846040001
};
const REPRESENTATION_OPTIONS = {
QUESTIONNAIRE: "Questionnaire",
STATEMENT: "Statement",
FINAL_COMMENTS: "Final comments",
CONSULTATION_RESPONSE: "Consultation Response",
LOCAL_IMPACT_REPORT: "Local Impact Report",
MARINE_IMPACT_REPORT: "Marine Impact Report"
};
const isValidDate = (date) =>
date instanceof Date && !Number.isNaN(date.getTime());
const isWithinWindow = (now, start, end) => {
if (!isValidDate(now) || !isValidDate(start) || !isValidDate(end)) {
return false;
}
return now >= start && now <= end;
};
const addQuestionnaire = (options, ctx) => {
const excludedTypes = [
APPEAL_TYPES.EXCLUDE_QUESTIONNAIRE_1,
APPEAL_TYPES.DNS,
APPEAL_TYPES.EXCLUDE_QUESTIONNAIRE_2,
APPEAL_TYPES.EXCLUDE_QUESTIONNAIRE_3
];
const canAdd =
ctx.isLPA &&
!excludedTypes.includes(ctx.appealType) &&
isWithinWindow(ctx.now, ctx.startDate, ctx.finalCommentsDueDate);
if (canAdd) {
options.add(REPRESENTATION_OPTIONS.QUESTIONNAIRE);
}
};
const addStatements = (options, ctx) => {
const isLpaAdvertPart3NoStatement =
ctx.isLPA &&
ctx.appealType === APPEAL_TYPES.ADVERT &&
ctx.specialistProcess === SPECIALIST_PROCESS.WRITTEN_REPS;
const canAddDnsLpaDocs =
ctx.isLPA &&
ctx.isDNS &&
ctx.appealType !== APPEAL_TYPES.EXCLUDE_QUESTIONNAIRE_1 &&
!ctx.isCaseOwner &&
isWithinWindow(ctx.now, ctx.startDate, ctx.finalCommentsDueDate);
if (canAddDnsLpaDocs) {
options.add(REPRESENTATION_OPTIONS.LOCAL_IMPACT_REPORT);
options.add(REPRESENTATION_OPTIONS.STATEMENT);
}
const canAddStatementForNonDns =
!ctx.isDNS &&
ctx.appealType !== APPEAL_TYPES.HOUSEHOLDER &&
!isLpaAdvertPart3NoStatement &&
!ctx.isCaseOwner &&
isWithinWindow(ctx.now, ctx.startDate, ctx.statementDueDate);
if (canAddStatementForNonDns) {
options.add(REPRESENTATION_OPTIONS.STATEMENT);
}
const canAddStatementForDnsNonLpa =
ctx.isDNS &&
!ctx.isLPA &&
!ctx.isCaseOwner &&
isWithinWindow(ctx.now, ctx.startDate, ctx.statementDueDate);
if (canAddStatementForDnsNonLpa) {
options.add(REPRESENTATION_OPTIONS.STATEMENT);
}
};
const addFinalComments = (options, ctx) => {
const canSubmitFinalCommentsDefault =
ctx.isSelectedAppellant ||
ctx.isSelectedAgent ||
ctx.isSelectedInterestedParty ||
ctx.isLPA;
const canSubmitFinalComments = ctx.isDNS
? ctx.isLPA
: canSubmitFinalCommentsDefault;
const isSpecialistNonHearing =
ctx.specialistProcess !== SPECIALIST_PROCESS.HEARING;
const finalCommentsWindowStart = ctx.isDNS
? ctx.startDate
: ctx.statementDueDate;
const canAdd =
canSubmitFinalComments &&
isWithinWindow(
ctx.now,
finalCommentsWindowStart,
ctx.finalCommentsDueDate
) &&
(ctx.appealType !== APPEAL_TYPES.HOUSEHOLDER || isSpecialistNonHearing);
if (canAdd) {
options.add(REPRESENTATION_OPTIONS.FINAL_COMMENTS);
}
};
const addConsultation = (options, ctx) => {
if (!ctx.isSIPS) return;
options.add(REPRESENTATION_OPTIONS.CONSULTATION_RESPONSE);
if (!ctx.isSelectedAppellant) {
options.add(REPRESENTATION_OPTIONS.LOCAL_IMPACT_REPORT);
}
if (ctx.isNRW) {
options.add(REPRESENTATION_OPTIONS.MARINE_IMPACT_REPORT);
}
};
export const getAvailableRepresentationTypes = (ctx) => {
const options = new Set();
addQuestionnaire(options, ctx);
addStatements(options, ctx);
addFinalComments(options, ctx);
addConsultation(options, ctx);
return [...options];
};
@@ -0,0 +1 @@
export { getAvailableRepresentationTypes } from "./getAvailableRepresentationTypes";
@@ -16,6 +16,10 @@ const loadBuildRepsArrRulesModule = () => {
);
let source = fs.readFileSync(filePath, "utf8");
source = source.replace(
/const\s+\{\s*getAvailableRepresentationTypes\s*\}\s*=\s*require\("\.\.\.\.\/\.\.\.\/\.\.\.\/lib\/domain\/representation-type-policy\/getAvailableRepresentationTypes"\);/,
'const { getAvailableRepresentationTypes } = require("../../../../lib/domain/representation-type-policy/getAvailableRepresentationTypes");'
);
source = source.replace(/export const\s+/g, "const ");
source += `
module.exports = {
@@ -30,7 +34,42 @@ module.exports = {
const context = {
module: { exports: {} },
exports: {},
require,
require: (modulePath) => {
if (
modulePath ===
"../../../../lib/domain/representation-type-policy/getAvailableRepresentationTypes"
) {
const domainFilePath = path.join(
rootDir,
"lib",
"domain",
"representation-type-policy",
"getAvailableRepresentationTypes.js"
);
let domainSource = fs.readFileSync(domainFilePath, "utf8");
domainSource = domainSource.replace(
/export const\s+getAvailableRepresentationTypes\s*=/g,
"const getAvailableRepresentationTypes ="
);
domainSource +=
"\nmodule.exports = { getAvailableRepresentationTypes };\n";
const domainContext = {
module: { exports: {} },
exports: {},
require,
Date
};
vm.runInNewContext(domainSource, domainContext, {
filename: domainFilePath
});
return domainContext.module.exports;
}
return require(modulePath);
},
Date
};
@@ -418,6 +457,94 @@ test("invalid or missing dates suppress date-gated options but SIPS consultation
);
});
test("non-DNS startdate plural fallback preserves successful statement availability and current output order", () => {
assert.deepStrictEqual(
buildOptions({
detailsObj: {
pinswg_startdate: null,
pinswg_startdates: "2026-04-01T00:00:00.000Z",
pinswg_statementduedate: "2026-04-30T00:00:00.000Z",
pinswg_finalcommentsduedate: "2026-05-25T00:00:00.000Z"
},
selectedCapacity: "appellant",
isCaseOwner: false,
now: new Date("2026-04-15T10:00:00.000Z")
}),
["Statement"]
);
assert.deepStrictEqual(
buildOptions({
detailsObj: {
pinswg_startdate: null,
pinswg_startdates: "2026-04-01T00:00:00.000Z",
pinswg_statementduedate: "2026-04-30T00:00:00.000Z",
pinswg_finalcommentsduedate: "2026-05-25T00:00:00.000Z"
},
selectedCapacity: "agent",
involvementType: INVOLVEMENT_TYPES.LPA,
isCaseOwner: false,
isNRW: true,
appealType: APPEAL_TYPES.SIPS,
now: new Date("2026-05-10T10:00:00.000Z")
}),
[
"Questionnaire",
"Final comments",
"Consultation Response",
"Local Impact Report",
"Marine Impact Report"
]
);
});
test("non-DNS statementsduedate plural fallback preserves successful final comments availability", () => {
assert.deepStrictEqual(
buildOptions({
detailsObj: {
pinswg_startdate: "2026-04-01T00:00:00.000Z",
pinswg_statementduedate: null,
pinswg_statementsduedate: "2026-04-30T00:00:00.000Z",
pinswg_finalcommentsduedate: "2026-05-25T00:00:00.000Z"
},
selectedCapacity: "interestedparty",
isCaseOwner: false,
now: new Date("2026-05-10T10:00:00.000Z")
}),
["Final comments"]
);
});
test("combined plural date fallbacks preserve successful statement and final comments availability across windows", () => {
const detailsObj = {
pinswg_startdate: null,
pinswg_startdates: "2026-04-01T00:00:00.000Z",
pinswg_statementduedate: null,
pinswg_statementsduedate: "2026-04-30T00:00:00.000Z",
pinswg_finalcommentsduedate: "2026-05-25T00:00:00.000Z"
};
assert.deepStrictEqual(
buildOptions({
detailsObj,
selectedCapacity: "appellant",
isCaseOwner: false,
now: new Date("2026-04-15T10:00:00.000Z")
}),
["Statement"]
);
assert.deepStrictEqual(
buildOptions({
detailsObj,
selectedCapacity: "appellant",
isCaseOwner: false,
now: new Date("2026-05-10T10:00:00.000Z")
}),
["Final comments"]
);
});
test("capacity normalization coverage", () => {
const inFinalWindow = { now: new Date("2026-05-10T10:00:00.000Z") };