diff --git a/lib/domain/appeal-type-policy/README.md b/lib/domain/appeal-type-policy/README.md new file mode 100644 index 00000000..f60a6020 --- /dev/null +++ b/lib/domain/appeal-type-policy/README.md @@ -0,0 +1,128 @@ +# Appeal Type Policy + +## Purpose + +This boundary currently owns one narrow question only: + +> **What family does this appeal type belong to?** + +It does not currently own any broader appeal-type behaviour. + +This boundary exists to provide a thin, reusable identity/family layer without introducing duplicate mappings or broad appeal-type policy abstractions too early. + +--- + +## Current ownership + +The boundary currently exposes: + +- `getAppealTypeFamily(appealTypeId)` + +This helper answers which family a supplied appeal type belongs to, while preserving the application’s existing behaviour exactly. + +--- + +## Current source of truth + +`getAppealTypeFamily(...)` currently delegates to: + +- `lib/domain/case-lifecycle/mapAppealType.js` + +This is intentional for now. + +The goal is to avoid creating duplicate appeal-type family mappings while the family contract is still being stabilised through small characterization and adoption slices. + +At this stage, this boundary is a thin wrapper around the existing source of truth rather than an independent mapping table. + +--- + +## Current consumers + +The boundary is currently consumed by: + +- `lib/domain/representation-policy/` +- `lib/domain/representation-type-policy/` + +Adoption has been limited to rules that were clearly family/group-based. + +--- + +## Explicit non-goals + +This boundary does **not** currently own: + +- appeal type rollout control +- `pages/api/endpoint/getappealtypesfornewappeal_api.js` +- lifecycle stages +- query-field selection +- CRM field selection +- representation windows +- representation availability +- dashboard behaviour +- capability matrices +- specialist-process logic + +Those concerns remain in their existing domain or application-specific homes. + +--- + +## Family vs specific-type rules + +An important distinction emerged during adoption. + +Some rules are genuinely **family-based**, for example: + +- DNS +- SIP / SIPS + +Some rules are genuinely **appeal-type specific**, for example: + +- Householder +- Advert +- CPO +- Rights of Way +- Common Land + +Because of this, raw appeal-type IDs should **not** automatically be replaced with family classification. + +Family classification should only be adopted where the rule is clearly about a broader family/group identity. + +--- + +## Known behavioural contracts + +The current behaviour of `getAppealTypeFamily(...)` is characterized and should be preserved. + +That includes: + +- numeric inputs +- string inputs +- alias mappings +- unknown values +- `null` / `undefined` handling + +Examples of preserved behaviour include: + +- known numeric mappings returning the current family key +- known aliases resolving to the current family key +- unknown values falling back to current behaviour +- `null` / `undefined` preserving current behaviour rather than being normalised + +This helper should not be “cleaned up” or broadened without characterization first. + +--- + +## Future guidance + +Future slices should: + +- characterize first +- adopt selectively +- avoid broad replacement of appeal-type checks + +Future work should continue to distinguish between: + +- **identity/family classification** +- **specific appeal-type business rules** + +This boundary should remain narrow until there is strong evidence that additional shared ownership is both stable and behaviour-preserving. diff --git a/lib/domain/appeal-type-policy/getAppealTypeFamily.js b/lib/domain/appeal-type-policy/getAppealTypeFamily.js new file mode 100644 index 00000000..751a75ce --- /dev/null +++ b/lib/domain/appeal-type-policy/getAppealTypeFamily.js @@ -0,0 +1,5 @@ +import { mapAppealType } from "../case-lifecycle/mapAppealType"; + +export function getAppealTypeFamily(appealTypeId) { + return mapAppealType(appealTypeId); +} diff --git a/lib/domain/appeal-type-policy/index.js b/lib/domain/appeal-type-policy/index.js new file mode 100644 index 00000000..2ead3b41 --- /dev/null +++ b/lib/domain/appeal-type-policy/index.js @@ -0,0 +1 @@ +export { getAppealTypeFamily } from "./getAppealTypeFamily"; diff --git a/lib/domain/representation-policy/canShowRepresentationButtonForAppealType.js b/lib/domain/representation-policy/canShowRepresentationButtonForAppealType.js index 5f293373..48f6e248 100644 --- a/lib/domain/representation-policy/canShowRepresentationButtonForAppealType.js +++ b/lib/domain/representation-policy/canShowRepresentationButtonForAppealType.js @@ -1,7 +1,20 @@ -const EXCLUDED_APPEAL_TYPE_IDS = new Set([ - 846040012, 846040013, 846040014, 846040020, 846040021, 846040023, 846040024 +import { getAppealTypeFamily } from "../appeal-type-policy"; + +const EXCLUDED_APPEAL_TYPE_FAMILIES = new Set([ + "ELECTRICITY_ACT", + "TRANSPORT_WORKS", + "HARBOUR_REVISION_ORDER", + "WAYLEAVE", + "NON_VALIDATION" ]); +const EXCLUDED_APPEAL_TYPE_IDS = new Set([846040020, 846040023]); + export function canShowRepresentationButtonForAppealType(appealType) { - return EXCLUDED_APPEAL_TYPE_IDS.has(Number(appealType)) ? false : true; + const appealTypeFamily = getAppealTypeFamily(appealType); + + return EXCLUDED_APPEAL_TYPE_FAMILIES.has(appealTypeFamily) || + EXCLUDED_APPEAL_TYPE_IDS.has(Number(appealType)) + ? false + : true; } diff --git a/lib/domain/representation-type-policy/getAvailableRepresentationTypes.js b/lib/domain/representation-type-policy/getAvailableRepresentationTypes.js index b6ade0ad..8532f945 100644 --- a/lib/domain/representation-type-policy/getAvailableRepresentationTypes.js +++ b/lib/domain/representation-type-policy/getAvailableRepresentationTypes.js @@ -1,3 +1,5 @@ +import { getAppealTypeFamily } from "../appeal-type-policy"; + const APPEAL_TYPES = { SIPS: 846040002, DNS: 846040011, @@ -22,6 +24,11 @@ const REPRESENTATION_OPTIONS = { MARINE_IMPACT_REPORT: "Marine Impact Report" }; +const APPEAL_TYPE_FAMILIES = { + DNS: "DNS", + SIPS: "SIP" +}; + const isValidDate = (date) => date instanceof Date && !Number.isNaN(date.getTime()); @@ -34,15 +41,16 @@ const isWithinWindow = (now, start, end) => { }; const addQuestionnaire = (options, ctx) => { + const appealTypeFamily = getAppealTypeFamily(ctx.appealType); const excludedTypes = [ APPEAL_TYPES.EXCLUDE_QUESTIONNAIRE_1, - APPEAL_TYPES.DNS, APPEAL_TYPES.EXCLUDE_QUESTIONNAIRE_2, APPEAL_TYPES.EXCLUDE_QUESTIONNAIRE_3 ]; const canAdd = ctx.isLPA && + appealTypeFamily !== APPEAL_TYPE_FAMILIES.DNS && !excludedTypes.includes(ctx.appealType) && isWithinWindow(ctx.now, ctx.startDate, ctx.finalCommentsDueDate); @@ -52,6 +60,9 @@ const addQuestionnaire = (options, ctx) => { }; const addStatements = (options, ctx) => { + const appealTypeFamily = getAppealTypeFamily(ctx.appealType); + const isDnsAppeal = appealTypeFamily === APPEAL_TYPE_FAMILIES.DNS; + const isLpaAdvertPart3NoStatement = ctx.isLPA && ctx.appealType === APPEAL_TYPES.ADVERT && @@ -59,7 +70,7 @@ const addStatements = (options, ctx) => { const canAddDnsLpaDocs = ctx.isLPA && - ctx.isDNS && + isDnsAppeal && ctx.appealType !== APPEAL_TYPES.EXCLUDE_QUESTIONNAIRE_1 && !ctx.isCaseOwner && isWithinWindow(ctx.now, ctx.startDate, ctx.finalCommentsDueDate); @@ -70,7 +81,7 @@ const addStatements = (options, ctx) => { } const canAddStatementForNonDns = - !ctx.isDNS && + !isDnsAppeal && ctx.appealType !== APPEAL_TYPES.HOUSEHOLDER && !isLpaAdvertPart3NoStatement && !ctx.isCaseOwner && @@ -81,7 +92,7 @@ const addStatements = (options, ctx) => { } const canAddStatementForDnsNonLpa = - ctx.isDNS && + isDnsAppeal && !ctx.isLPA && !ctx.isCaseOwner && isWithinWindow(ctx.now, ctx.startDate, ctx.statementDueDate); @@ -92,20 +103,23 @@ const addStatements = (options, ctx) => { }; const addFinalComments = (options, ctx) => { + const appealTypeFamily = getAppealTypeFamily(ctx.appealType); + const isDnsAppeal = appealTypeFamily === APPEAL_TYPE_FAMILIES.DNS; + const canSubmitFinalCommentsDefault = ctx.isSelectedAppellant || ctx.isSelectedAgent || ctx.isSelectedInterestedParty || ctx.isLPA; - const canSubmitFinalComments = ctx.isDNS + const canSubmitFinalComments = isDnsAppeal ? ctx.isLPA : canSubmitFinalCommentsDefault; const isSpecialistNonHearing = ctx.specialistProcess !== SPECIALIST_PROCESS.HEARING; - const finalCommentsWindowStart = ctx.isDNS + const finalCommentsWindowStart = isDnsAppeal ? ctx.startDate : ctx.statementDueDate; @@ -124,7 +138,9 @@ const addFinalComments = (options, ctx) => { }; const addConsultation = (options, ctx) => { - if (!ctx.isSIPS) return; + const appealTypeFamily = getAppealTypeFamily(ctx.appealType); + + if (appealTypeFamily !== APPEAL_TYPE_FAMILIES.SIPS) return; options.add(REPRESENTATION_OPTIONS.CONSULTATION_RESPONSE); diff --git a/tests/phase22/appeal-type-family.test.cjs b/tests/phase22/appeal-type-family.test.cjs new file mode 100644 index 00000000..7af54e51 --- /dev/null +++ b/tests/phase22/appeal-type-family.test.cjs @@ -0,0 +1,171 @@ +const fs = require("fs"); +const path = require("path"); +const vm = require("vm"); +const assert = require("assert"); + +const rootDir = path.resolve(__dirname, "..", ".."); + +const loadMapAppealTypeModule = () => { + const filePath = path.join( + rootDir, + "lib", + "domain", + "case-lifecycle", + "mapAppealType.js" + ); + + let source = fs.readFileSync(filePath, "utf8"); + source = source.replace(/export const\s+/g, "const "); + source = source.replace( + /export function\s+mapAppealType/, + "function mapAppealType" + ); + source += ` +module.exports = { + caseTypeAliases, + caseTypeKeyByAppealTypeId, + normaliseCaseType, + mapAppealType +}; +`; + + const context = { + module: { exports: {} }, + exports: {}, + require + }; + + vm.runInNewContext(source, context, { filename: filePath }); + return context.module.exports; +}; + +const loadGetAppealTypeFamily = () => { + const filePath = path.join( + rootDir, + "lib", + "domain", + "appeal-type-policy", + "getAppealTypeFamily.js" + ); + + let source = fs.readFileSync(filePath, "utf8"); + source = source.replace( + /import\s+\{\s*mapAppealType\s*\}\s+from\s+"\.\.\/case-lifecycle\/mapAppealType";?/, + "" + ); + source = source.replace( + /export function\s+getAppealTypeFamily/, + "function getAppealTypeFamily" + ); + source += "\nmodule.exports = { getAppealTypeFamily };\n"; + + const context = { + module: { exports: {} }, + exports: {}, + require, + mapAppealType: mapAppealTypeModule.mapAppealType + }; + + vm.runInNewContext(source, context, { filename: filePath }); + return context.module.exports; +}; + +const tests = []; +const test = (name, fn) => tests.push({ name, fn }); + +const mapAppealTypeModule = loadMapAppealTypeModule(); +const { getAppealTypeFamily } = loadGetAppealTypeFamily(); + +test("known mappings are preserved for representative appeal types", () => { + assert.strictEqual(getAppealTypeFamily(846040000), "PLANNING_S78"); + assert.strictEqual(getAppealTypeFamily(846040009), "PLANNING_S78"); + assert.strictEqual(getAppealTypeFamily(846040020), "PLANNING_S78"); + assert.strictEqual(getAppealTypeFamily(846040011), "DNS"); + assert.strictEqual(getAppealTypeFamily(846040002), "SIP"); + assert.strictEqual( + getAppealTypeFamily(846040015), + "RIGHTS_OF_WAY_SCHEDULE_14" + ); +}); + +test("alias mappings are preserved exactly", () => { + assert.strictEqual(getAppealTypeFamily("S78"), "PLANNING_S78"); + assert.strictEqual(getAppealTypeFamily("PLANNING_78"), "PLANNING_S78"); + assert.strictEqual(getAppealTypeFamily("CALL_IN"), "CALL_INS"); + assert.strictEqual(getAppealTypeFamily("DNS"), "DNS"); +}); + +test("numeric and numeric-string inputs preserve current behaviour", () => { + assert.strictEqual(getAppealTypeFamily(846040004), "HAS"); + assert.strictEqual(getAppealTypeFamily("846040004"), "HAS"); + assert.strictEqual(getAppealTypeFamily("846040011"), "DNS"); +}); + +test("unknown inputs preserve current fallback behaviour", () => { + assert.strictEqual(getAppealTypeFamily(999999999), "999999999"); + assert.strictEqual( + getAppealTypeFamily("UNKNOWN_CASE_TYPE"), + "UNKNOWN_CASE_TYPE" + ); +}); + +test("null and undefined preserve current behaviour", () => { + assert.strictEqual(getAppealTypeFamily(null), undefined); + assert.strictEqual(getAppealTypeFamily(undefined), undefined); +}); + +test("existing normalization fallback behaviour is preserved for raw string labels", () => { + assert.strictEqual(getAppealTypeFamily("planning s78"), "PLANNING_S78"); + assert.strictEqual( + getAppealTypeFamily("Rights of Way Schedule 14"), + "RIGHTS_OF_WAY_SCHEDULE_14" + ); +}); + +test("new helper preserves parity with existing source-of-truth behaviour", () => { + const scenarios = [ + 846040000, + 846040009, + 846040020, + 846040011, + 846040002, + 846040015, + 846040004, + 846040010, + "S78", + "CALL_IN", + "UNKNOWN_CASE_TYPE", + 999999999, + null, + undefined + ]; + + for (const scenario of scenarios) { + assert.strictEqual( + getAppealTypeFamily(scenario), + mapAppealTypeModule.mapAppealType(scenario) + ); + } +}); + +const run = async () => { + let passed = 0; + + for (const currentTest of tests) { + await currentTest.fn(); + passed += 1; + } + + console.log( + `Phase 22 appeal-type-family tests passed (${passed}/${tests.length}).` + ); +}; + +module.exports = run; + +if (require.main === module) { + run().catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/tests/phase22/representation-advert-entry-rule.test.cjs b/tests/phase22/representation-advert-entry-rule.test.cjs index 484a6f87..d6cef5dd 100644 --- a/tests/phase22/representation-advert-entry-rule.test.cjs +++ b/tests/phase22/representation-advert-entry-rule.test.cjs @@ -5,6 +5,77 @@ const assert = require("assert"); const rootDir = path.resolve(__dirname, "..", ".."); +const loadMapAppealTypeModule = () => { + const filePath = path.join( + rootDir, + "lib", + "domain", + "case-lifecycle", + "mapAppealType.js" + ); + + let source = fs.readFileSync(filePath, "utf8"); + source = source.replace(/export const\s+/g, "const "); + source = source.replace( + /export function\s+mapAppealType/, + "function mapAppealType" + ); + source += ` +module.exports = { + caseTypeAliases, + caseTypeKeyByAppealTypeId, + normaliseCaseType, + mapAppealType +}; +`; + + const context = { + module: { exports: {} }, + exports: {}, + require + }; + + vm.runInNewContext(source, context, { filename: filePath }); + return context.module.exports; +}; + +const loadAppealTypeFamilyModule = () => { + const filePath = path.join( + rootDir, + "lib", + "domain", + "appeal-type-policy", + "getAppealTypeFamily.js" + ); + + let source = fs.readFileSync(filePath, "utf8"); + source = source.replace( + /import\s+\{\s*mapAppealType\s*\}\s+from\s+"\.\.\/case-lifecycle\/mapAppealType";?/, + 'const { mapAppealType } = require("../case-lifecycle/mapAppealType");' + ); + source = source.replace( + /export function\s+getAppealTypeFamily/, + "function getAppealTypeFamily" + ); + source += ` +module.exports = { getAppealTypeFamily }; +`; + + const context = { + module: { exports: {} }, + exports: {}, + require: (modulePath) => { + if (modulePath === "../case-lifecycle/mapAppealType") { + return loadMapAppealTypeModule(); + } + return require(modulePath); + } + }; + + vm.runInNewContext(source, context, { filename: filePath }); + return context.module.exports; +}; + const loadEsModuleFunctions = ( relativePath, exportNames, @@ -28,13 +99,22 @@ const loadEsModuleFunctions = ( /import\s+\{\s*isRepresentationWindowOpen\s*\}\s+from\s+"\.\/resolveRepresentationWindow";/, 'const { isRepresentationWindowOpen } = require("./resolveRepresentationWindow");' ); + source = source.replace( + /import\s+\{\s*getAppealTypeFamily\s*\}\s+from\s+"\.\.\/appeal-type-policy";?/, + 'const { getAppealTypeFamily } = require("../appeal-type-policy");' + ); source += `\nmodule.exports = { ${exportNames.join(", ")} };\n`; const context = { module: { exports: {} }, exports: {}, - require, + require: (modulePath) => { + if (modulePath === "../appeal-type-policy") { + return loadAppealTypeFamilyModule(); + } + return require(modulePath); + }, Date, Number, Set, diff --git a/tests/phase22/representation-appeal-type-entry-gating.test.cjs b/tests/phase22/representation-appeal-type-entry-gating.test.cjs index 27551ac7..9b783291 100644 --- a/tests/phase22/representation-appeal-type-entry-gating.test.cjs +++ b/tests/phase22/representation-appeal-type-entry-gating.test.cjs @@ -5,6 +5,79 @@ const assert = require("assert"); const rootDir = path.resolve(__dirname, "..", ".."); +const loadAppealTypeFamilyModule = () => { + const filePath = path.join( + rootDir, + "lib", + "domain", + "appeal-type-policy", + "getAppealTypeFamily.js" + ); + + let source = fs.readFileSync(filePath, "utf8"); + source = source.replace( + /import\s+\{\s*mapAppealType\s*\}\s+from\s+"\.\.\/case-lifecycle\/mapAppealType";?/, + 'const { mapAppealType } = require("../case-lifecycle/mapAppealType");' + ); + source = source.replace( + /export function\s+getAppealTypeFamily/, + "function getAppealTypeFamily" + ); + source += ` +module.exports = { + getAppealTypeFamily +}; +`; + + const context = { + module: { exports: {} }, + exports: {}, + require: (modulePath) => { + if (modulePath === "../case-lifecycle/mapAppealType") { + return loadMapAppealTypeModule(); + } + return require(modulePath); + } + }; + + vm.runInNewContext(source, context, { filename: filePath }); + return context.module.exports; +}; + +const loadMapAppealTypeModule = () => { + const filePath = path.join( + rootDir, + "lib", + "domain", + "case-lifecycle", + "mapAppealType.js" + ); + + let source = fs.readFileSync(filePath, "utf8"); + source = source.replace(/export const\s+/g, "const "); + source = source.replace( + /export function\s+mapAppealType/, + "function mapAppealType" + ); + source += ` +module.exports = { + caseTypeAliases, + caseTypeKeyByAppealTypeId, + normaliseCaseType, + mapAppealType +}; +`; + + const context = { + module: { exports: {} }, + exports: {}, + require + }; + + vm.runInNewContext(source, context, { filename: filePath }); + return context.module.exports; +}; + const loadGatingModule = () => { const filePath = path.join( rootDir, @@ -15,6 +88,10 @@ const loadGatingModule = () => { ); let source = fs.readFileSync(filePath, "utf8"); + source = source.replace( + /import\s+\{\s*getAppealTypeFamily\s*\}\s+from\s+"\.\.\/appeal-type-policy";?/, + 'const { getAppealTypeFamily } = require("../appeal-type-policy");' + ); source = source.replace( /export function\s+canShowRepresentationButtonForAppealType/, "function canShowRepresentationButtonForAppealType" @@ -28,7 +105,12 @@ module.exports = { const context = { module: { exports: {} }, exports: {}, - require, + require: (modulePath) => { + if (modulePath === "../appeal-type-policy") { + return loadAppealTypeFamilyModule(); + } + return require(modulePath); + }, Number, Set }; diff --git a/tests/phase22/representation-row-entry-rule.test.cjs b/tests/phase22/representation-row-entry-rule.test.cjs index e899c744..ea262e22 100644 --- a/tests/phase22/representation-row-entry-rule.test.cjs +++ b/tests/phase22/representation-row-entry-rule.test.cjs @@ -5,6 +5,77 @@ const assert = require("assert"); const rootDir = path.resolve(__dirname, "..", ".."); +const loadMapAppealTypeModule = () => { + const filePath = path.join( + rootDir, + "lib", + "domain", + "case-lifecycle", + "mapAppealType.js" + ); + + let source = fs.readFileSync(filePath, "utf8"); + source = source.replace(/export const\s+/g, "const "); + source = source.replace( + /export function\s+mapAppealType/, + "function mapAppealType" + ); + source += ` +module.exports = { + caseTypeAliases, + caseTypeKeyByAppealTypeId, + normaliseCaseType, + mapAppealType +}; +`; + + const context = { + module: { exports: {} }, + exports: {}, + require + }; + + vm.runInNewContext(source, context, { filename: filePath }); + return context.module.exports; +}; + +const loadAppealTypeFamilyModule = () => { + const filePath = path.join( + rootDir, + "lib", + "domain", + "appeal-type-policy", + "getAppealTypeFamily.js" + ); + + let source = fs.readFileSync(filePath, "utf8"); + source = source.replace( + /import\s+\{\s*mapAppealType\s*\}\s+from\s+"\.\.\/case-lifecycle\/mapAppealType";?/, + 'const { mapAppealType } = require("../case-lifecycle/mapAppealType");' + ); + source = source.replace( + /export function\s+getAppealTypeFamily/, + "function getAppealTypeFamily" + ); + source += ` +module.exports = { getAppealTypeFamily }; +`; + + const context = { + module: { exports: {} }, + exports: {}, + require: (modulePath) => { + if (modulePath === "../case-lifecycle/mapAppealType") { + return loadMapAppealTypeModule(); + } + return require(modulePath); + } + }; + + vm.runInNewContext(source, context, { filename: filePath }); + return context.module.exports; +}; + const createFakeDate = (isoString) => { const RealDate = Date; @@ -51,13 +122,22 @@ const loadEsModuleFunctions = ( /import\s+\{\s*isRepresentationWindowOpen\s*\}\s+from\s+"\.\/resolveRepresentationWindow";/, 'const { isRepresentationWindowOpen } = require("./resolveRepresentationWindow");' ); + source = source.replace( + /import\s+\{\s*getAppealTypeFamily\s*\}\s+from\s+"\.\.\/appeal-type-policy";?/, + 'const { getAppealTypeFamily } = require("../appeal-type-policy");' + ); source += `\nmodule.exports = { ${exportNames.join(", ")} };\n`; const context = { module: { exports: {} }, exports: {}, - require, + require: (modulePath) => { + if (modulePath === "../appeal-type-policy") { + return loadAppealTypeFamilyModule(); + } + return require(modulePath); + }, Date, Number, Set,