From 0b66a0e1d4dc3616466037262d89065e46f82e99 Mon Sep 17 00:00:00 2001 From: Robert Bond Date: Wed, 17 Jun 2026 10:55:32 +0000 Subject: [PATCH] Merged PR 2394: adjusting reps domain logic Related work items: #23754 --- .../representation-type-policy/README.md | 145 +++++++++++++++++ .../getAvailableRepresentationTypes.js | 147 ++++++++++++++++++ .../representation-type-policy/index.js | 1 + ...presentation-build-reps-arr-rules.test.cjs | 129 ++++++++++++++- 4 files changed, 421 insertions(+), 1 deletion(-) create mode 100644 lib/domain/representation-type-policy/README.md create mode 100644 lib/domain/representation-type-policy/getAvailableRepresentationTypes.js create mode 100644 lib/domain/representation-type-policy/index.js diff --git a/lib/domain/representation-type-policy/README.md b/lib/domain/representation-type-policy/README.md new file mode 100644 index 00000000..1fb95cb2 --- /dev/null +++ b/lib/domain/representation-type-policy/README.md @@ -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 +``` diff --git a/lib/domain/representation-type-policy/getAvailableRepresentationTypes.js b/lib/domain/representation-type-policy/getAvailableRepresentationTypes.js new file mode 100644 index 00000000..b6ade0ad --- /dev/null +++ b/lib/domain/representation-type-policy/getAvailableRepresentationTypes.js @@ -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]; +}; diff --git a/lib/domain/representation-type-policy/index.js b/lib/domain/representation-type-policy/index.js new file mode 100644 index 00000000..984f1a33 --- /dev/null +++ b/lib/domain/representation-type-policy/index.js @@ -0,0 +1 @@ +export { getAvailableRepresentationTypes } from "./getAvailableRepresentationTypes"; diff --git a/tests/phase22/representation-build-reps-arr-rules.test.cjs b/tests/phase22/representation-build-reps-arr-rules.test.cjs index a7775d22..44c712f6 100644 --- a/tests/phase22/representation-build-reps-arr-rules.test.cjs +++ b/tests/phase22/representation-build-reps-arr-rules.test.cjs @@ -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") };