Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
221 changes: 182 additions & 39 deletions VA_BACKLOG.md

Large diffs are not rendered by default.

108 changes: 108 additions & 0 deletions __tests__/safeguarding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,23 @@
expect(result.text).not.toContain('0808 800 4444');
});

test('PROFESSIONAL user does not see NSPCC adult helpline or warm sign-off', () => {
// Per v1.1 §1.4: NSPCC adult helpline is for non-professionals worried
// about a child; professionals call Children's Services directly. Warm
// sign-off is inappropriate register for the professional context.
const session = sessionAt('B3_AGE_CATEGORY', {
localAuthority: 'Birmingham',
userType: 'PROFESSIONAL',
isSupporter: true,
});
const result = select(session, 1);
expect(result.text).not.toContain('0808 800 5000'); // NSPCC number
expect(result.text).not.toContain('Please reach out when you feel ready');
// But still gets the professional opener and the 999 line
expect(result.text).toContain("Here are the Children's Services contacts");
expect(result.text).toContain('999');
});

});

// =============================================================================
Expand Down Expand Up @@ -307,6 +324,84 @@
expect(result.text).toContain('england.shelter.org.uk/housing_advice/homelessness/priority_need/at_risk_of_domestic_abuse');
});

test('DV __PROFESSIONAL exit fires when userType is PROFESSIONAL', () => {
// Per v1.1 §3.1: professional register replaces consolation framing
// with direct service framing.
const session = sessionAt('DV_CHILDREN_ASK', {
dvGender: 'Female',
isSupporter: true,
userType: 'PROFESSIONAL',
});
const result = select(session, 1);
expect(result.text).toContain('Here are the specialist contacts');
expect(result.text).toContain("You'll know your next steps from here");
expect(result.text).not.toContain("You don't have to work this out on your own");
// Service contacts and 999 line still present
expect(result.text).toContain('0808 2000 247');
expect(result.text).toContain('999');
});

test('DV __SUPPORTER exits all end with the 999 line (behavioural change per v1.1)', () => {
// Pre-v1.1 DV __SUPPORTER exits ended at the Shelter housing advice URL.
// v1.1 adds a 999 line to every safeguarding exit. Locking in the
// behavioural change so a regression cannot silently remove it.
// Parallel to the SA 999 test in 'Sexual Violence Disclosure'.
const NINE_NINE_NINE = "If they're in immediate danger, call 999.";
const cases: Array<[string, number]> = [
['Female', 1], ['Female', 2],
['Male', 1], ['Male', 2],
['Non-binary or other', 1], ['Non-binary or other', 2],
];
const endings = cases.map(([dvGender, childrenOption]) => {
const session = sessionAt('DV_CHILDREN_ASK', {
dvGender,
isSupporter: true,
userType: 'SUPPORTER',
});
const result = select(session, childrenOption);
return {
variant: `${dvGender} / children=${childrenOption === 1 ? 'yes' : 'no'}`,
ends999: result.text.trim().endsWith(NINE_NINE_NINE),
};
});
// Every DV __SUPPORTER exit must end with the 999 line.
expect(endings.filter((e) => !e.ends999)).toEqual([]);
});

});

// =============================================================================
// SEXUAL VIOLENCE - Must route to SA exit with appropriate helplines and 999
// =============================================================================

describe('Sexual Violence Disclosure', () => {

test('SA __SUPPORTER exits include the 999 line (behavioural change per v1.1)', () => {
// Pre-v1.1 SA exits did not include a 999 line. v1.1 adds one to every
// safeguarding exit. Locking in the behavioural change so a regression
// can't silently remove it.
const session = sessionAt('SA_GENDER_ASK', {
isSupporter: true,
userType: 'SUPPORTER',
});
const result = select(session, 1); // Female
expect(result.text).toContain("If they're in immediate danger, call 999");
});

test('SA __PROFESSIONAL exit fires when userType is PROFESSIONAL', () => {
const session = sessionAt('SA_GENDER_ASK', {
isSupporter: true,
userType: 'PROFESSIONAL',
});
const result = select(session, 1); // Female
expect(result.text).toContain('Here are the specialist contacts');
expect(result.text).toContain("You'll know your next steps from here");
expect(result.text).not.toContain("You don't have to work this out on your own");
// Service contact and 999 line still present
expect(result.text).toContain('0808 500 2222');
expect(result.text).toContain('999');
});

});

// =============================================================================
Expand All @@ -333,6 +428,19 @@
expect(result.sessionEnded).toBe(true);
});

test('SELF_HARM_EXIT__PROFESSIONAL resolves and uses direct register', () => {
// Note: buildSelfHarmExit (crisis.ts) is still inline and does not yet
// source from the SELF_HARM_EXIT phrasebank entries. This test asserts
// selector resolution only — the entry's pathway wiring is pending.
const entry = getPhrase('SELF_HARM_EXIT', 'PROFESSIONAL');
expect(entry?.text).toContain('Here are the immediate support contacts');
expect(entry?.text).toContain("You'll know what to do from here");
expect(entry?.text).toContain('A&E');
// Selector falls back through __PROFESSIONAL → __SUPPORTER → base.
// Confirm the PROFESSIONAL variant fired (not the SUPPORTER fallback).
expect(entry?.text).not.toContain("You don't have to work this out on your own");
});

});

// =============================================================================
Expand Down Expand Up @@ -1031,16 +1139,16 @@

// Find the safeguarding-trigger log call
const safeguardingCalls = logSpy.mock.calls.filter(
(c) => typeof c[0] === 'string' && c[0].includes('SAFEGUARDING_TRIGGER')

Check warning on line 1142 in __tests__/safeguarding.test.ts

View workflow job for this annotation

GitHub Actions / test

Unsafe member access [0] on an `any` value

Check warning on line 1142 in __tests__/safeguarding.test.ts

View workflow job for this annotation

GitHub Actions / test

Unsafe member access [0] on an `any` value

Check warning on line 1142 in __tests__/safeguarding.test.ts

View workflow job for this annotation

GitHub Actions / test

Unsafe member access [0] on an `any` value

Check warning on line 1142 in __tests__/safeguarding.test.ts

View workflow job for this annotation

GitHub Actions / test

Unsafe member access [0] on an `any` value
);
expect(safeguardingCalls.length).toBeGreaterThan(0);

const payload = JSON.parse(safeguardingCalls[0][1]);

Check warning on line 1146 in __tests__/safeguarding.test.ts

View workflow job for this annotation

GitHub Actions / test

Unsafe member access [1] on an `any` value

Check warning on line 1146 in __tests__/safeguarding.test.ts

View workflow job for this annotation

GitHub Actions / test

Unsafe argument of type `any` assigned to a parameter of type `string`

Check warning on line 1146 in __tests__/safeguarding.test.ts

View workflow job for this annotation

GitHub Actions / test

Unsafe assignment of an `any` value

Check warning on line 1146 in __tests__/safeguarding.test.ts

View workflow job for this annotation

GitHub Actions / test

Unsafe member access [1] on an `any` value

Check warning on line 1146 in __tests__/safeguarding.test.ts

View workflow job for this annotation

GitHub Actions / test

Unsafe argument of type `any` assigned to a parameter of type `string`

Check warning on line 1146 in __tests__/safeguarding.test.ts

View workflow job for this annotation

GitHub Actions / test

Unsafe assignment of an `any` value
expect(payload.type).toBe('AGE_NUMERIC');

Check warning on line 1147 in __tests__/safeguarding.test.ts

View workflow job for this annotation

GitHub Actions / test

Unsafe member access .type on an `any` value

Check warning on line 1147 in __tests__/safeguarding.test.ts

View workflow job for this annotation

GitHub Actions / test

Unsafe member access .type on an `any` value
expect(payload.code).toBe('AGE_NUMERIC_14');

Check warning on line 1148 in __tests__/safeguarding.test.ts

View workflow job for this annotation

GitHub Actions / test

Unsafe member access .code on an `any` value

Check warning on line 1148 in __tests__/safeguarding.test.ts

View workflow job for this annotation

GitHub Actions / test

Unsafe member access .code on an `any` value
expect(payload.sessionId).toBe('test-session-abc-123');

Check warning on line 1149 in __tests__/safeguarding.test.ts

View workflow job for this annotation

GitHub Actions / test

Unsafe member access .sessionId on an `any` value

Check warning on line 1149 in __tests__/safeguarding.test.ts

View workflow job for this annotation

GitHub Actions / test

Unsafe member access .sessionId on an `any` value
expect(payload.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/);

Check warning on line 1150 in __tests__/safeguarding.test.ts

View workflow job for this annotation

GitHub Actions / test

Unsafe member access .timestamp on an `any` value

Check warning on line 1150 in __tests__/safeguarding.test.ts

View workflow job for this annotation

GitHub Actions / test

Unsafe member access .timestamp on an `any` value
expect(payload.event).toBe('UNDER_16_DETECTED');

Check warning on line 1151 in __tests__/safeguarding.test.ts

View workflow job for this annotation

GitHub Actions / test

Unsafe member access .event on an `any` value

Check warning on line 1151 in __tests__/safeguarding.test.ts

View workflow job for this annotation

GitHub Actions / test

Unsafe member access .event on an `any` value
expect(payload.pathwayChange).toBe('TERMINATED_TO_UNDER_16_EXIT');

// Verify NO log call across the whole spy contains the raw user text
Expand Down
22 changes: 11 additions & 11 deletions lib/handlers/crisis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,10 +212,10 @@ function buildLocalDVInfo(session: SessionState): string {
export function handleCrisisDanger(session: SessionState, choice: number | null): RoutingResult {
switch (choice) {
case 1: // Immediate danger
return safeguardingExit('IMMEDIATE_PHYSICAL_DANGER_EXIT', session.isSupporter, 'IMMEDIATE_DANGER');
return safeguardingExit('IMMEDIATE_PHYSICAL_DANGER_EXIT', session.userType, 'IMMEDIATE_DANGER');
case 2: // Under 16 -> ask location first
return {
...phrase('CRISIS_UNDER16_LOCATION', session.isSupporter),
...phrase('CRISIS_UNDER16_LOCATION', session.userType),
stateUpdates: {
currentGate: 'CRISIS_UNDER16_LOCATION',
safeguardingTriggered: true,
Expand All @@ -225,18 +225,18 @@ export function handleCrisisDanger(session: SessionState, choice: number | null)
case 3: // Self-harm
return buildSelfHarmExit(session);
case 4: // Domestic abuse -> ask gender
return phrase('DV_GENDER_ASK', session.isSupporter);
return phrase('DV_GENDER_ASK', session.userType);
case 5: // Sexual violence -> ask gender
return phrase('SA_GENDER_ASK', session.isSupporter);
return phrase('SA_GENDER_ASK', session.userType);
case 6: // Fire/flood -> ask location first
return {
...phrase('CRISIS_FIRE_FLOOD_LOCATION', session.isSupporter),
...phrase('CRISIS_FIRE_FLOOD_LOCATION', session.userType),
stateUpdates: { currentGate: 'CRISIS_FIRE_FLOOD_LOCATION' }
};
case 7: // None apply — collect location before intent
return phrase('LOCATION_CONSENT', session.isSupporter);
return phrase('LOCATION_CONSENT', session.userType);
default:
return phrase('GATE0_CRISIS_DANGER', session.isSupporter);
return phrase('GATE0_CRISIS_DANGER', session.userType);
}
}

Expand All @@ -248,7 +248,7 @@ export function handleCrisisUnder16Location(session: SessionState, choice: numbe
return buildUnder16Exit({ ...session, localAuthority: la });
}
// Somewhere else — no LA to look up, so surface Childline directly
const exit = phrase('CRISIS_UNDER16_SOMEWHERE_ELSE', session.isSupporter);
const exit = phrase('CRISIS_UNDER16_SOMEWHERE_ELSE', session.userType);
return {
...exit,
stateUpdates: {
Expand Down Expand Up @@ -277,15 +277,15 @@ export function handleDVGenderAsk(session: SessionState, choice: number | null):
const dvGenders = ['Female', 'Male', 'Non-binary or other', 'Prefer not to say'];
const dvGender = choice ? dvGenders[choice - 1] : null;
return {
...phrase('DV_CHILDREN_ASK', session.isSupporter),
...phrase('DV_CHILDREN_ASK', session.userType),
stateUpdates: { currentGate: 'DV_CHILDREN_ASK', dvGender }
};
}

export function handleDVChildrenAsk(session: SessionState, choice: number | null): RoutingResult {
const dvChildren = choice === 1;
const dvExitKey = getDVExitKey(session.dvGender, dvChildren);
const result = safeguardingExit(dvExitKey, session.isSupporter, 'DOMESTIC_ABUSE');
const result = safeguardingExit(dvExitKey, session.userType, 'DOMESTIC_ABUSE');
const localDV = buildLocalDVInfo(session);
return { ...result, text: result.text + localDV };
}
Expand All @@ -294,7 +294,7 @@ export function handleSAGenderAsk(session: SessionState, choice: number | null):
const saGenders = ['Female', 'Male', 'Non-binary or other', 'Prefer not to say'];
const saGender = choice ? saGenders[choice - 1] : null;
const saExitKey = getSAExitKey(saGender);
const result = safeguardingExit(saExitKey, session.isSupporter, 'SEXUAL_VIOLENCE');
const result = safeguardingExit(saExitKey, session.userType, 'SEXUAL_VIOLENCE');
const sarcInfo = buildSARCInfo(session);
return { ...result, text: result.text + sarcInfo };
}
53 changes: 25 additions & 28 deletions lib/handlers/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ export function phrase(key: string, audience: 'SELF' | 'SUPPORTER' | 'PROFESSION
};
}

export function safeguardingExit(key: string, isSupporter: boolean, type: string): RoutingResult {
const p = getPhrase(key, isSupporter);
export function safeguardingExit(key: string, audience: 'SELF' | 'SUPPORTER' | 'PROFESSIONAL' | null | boolean, type: string): RoutingResult {
const p = getPhrase(key, audience);
return {
text: p?.text || `[Missing phrase: ${key}]`,
stateUpdates: {
Expand All @@ -45,25 +45,16 @@ export const childrenServicesData = Object.fromEntries(
) as Record<string, { name: string; phone: string; phoneOOH?: string; website: string }>;

export function buildUnder16Exit(session: SessionState): RoutingResult {
const isSupporter = session.isSupporter;
const la = session.localAuthority?.toLowerCase().replace(/\s+/g, '') || '';
const childServices = childrenServicesData[la];

let text = '';

if (isSupporter) {
text += `Thank you for reaching out. Because they are under 16, there are specialist services that can help keep them safe. It's really good that you're looking for support for them.\n\n`;
} else {
text += `Thank you for reaching out. Because you are under 16, there are specialist services that can help keep you safe. It takes courage to ask for help, and you've done the right thing.\n\n`;
}

// Local Children's Services — LA must be known by this point.
// All call sites should route through CRISIS_UNDER16_LOCATION first
// if LA is not yet set, so this is never reached without a valid LA.
// If it IS reached without one, log the error and recover by asking for area.
if (!childServices) {
console.error(`[VA] buildUnder16Exit called without valid LA (got: ${JSON.stringify(session.localAuthority)}, sessionId: ${session.sessionId}). Recovering to CRISIS_UNDER16_LOCATION.`);
const locationPhrase = getPhrase('CRISIS_UNDER16_LOCATION', isSupporter);
const locationPhrase = getPhrase('CRISIS_UNDER16_LOCATION', session.userType);
return {
text: locationPhrase?.text || '',
options: locationPhrase?.options,
Expand All @@ -75,6 +66,12 @@ export function buildUnder16Exit(session: SessionState): RoutingResult {
};
}

let text = '';

// Opener
text += getPhrase('UNDER16_EXIT_OPENER', session.userType)?.text || '';

// Local Children's Services — data-driven, stays inline
text += `CHILDREN'S SERVICES\n`;
text += `${childServices.name}\n`;
if (childServices.phoneOOH) {
Expand All @@ -83,27 +80,27 @@ export function buildUnder16Exit(session: SessionState): RoutingResult {
text += `${childServices.phone}\n`;
}
text += `${childServices.website}\n`;
text += `They can talk through what's happening and help work out the best support\n\n`;

// Next-steps reinforcement
text += getPhrase('UNDER16_EXIT_NEXT_STEPS', session.userType)?.text || '';

// Childline
text += `SPECIALIST HELPLINE\n`;
text += `Childline\n`;
text += `0800 1111 (free, confidential, 24/7)\n`;
text += `https://www.childline.org.uk\n`;
text += `${isSupporter ? 'A free helpline for young people to call or chat online about anything' : 'A free helpline where you can call or chat online about anything'}\n\n`;
text += getPhrase('UNDER16_EXIT_CHILDLINE_FOR_YOUNG_PERSON', session.userType)?.text || '';

if (isSupporter) {
text += `SPECIALIST HELPLINE\n`;
text += `NSPCC Helpline (for adults)\n`;
text += `0808 800 5000 (free, 24/7)\n`;
text += `https://www.nspcc.org.uk/keeping-children-safe/reporting-abuse/\n`;
text += `For adults who are worried about a child\n\n`;
// NSPCC and warm sign-off — included for SELF and SUPPORTER, excluded for
// PROFESSIONAL per Supporter and Professional Language Review v1.1 §1.4.
// NSPCC adult helpline is for non-professionals worried about a child;
// professionals call Children's Services directly. Warm sign-off is
// inappropriate register for the professional context.
// (For SELF, NSPCC has no base entry so the selector returns null —
// contributes nothing — preserving prior behaviour.)
if (session.userType !== 'PROFESSIONAL') {
text += getPhrase('UNDER16_EXIT_NSPCC_FOR_ADULT', session.userType)?.text || '';
text += getPhrase('UNDER16_EXIT_SIGN_OFF', session.userType)?.text || '';
}

// Warm sign-off with separator
text += `---\n`;
text += `Please reach out when you feel ready. I'll be here if you need help finding other services later.\n\n`;
text += `If ${isSupporter ? 'they are' : 'you are'} in immediate danger, call 999.`;
// 999 line
text += getPhrase('UNDER16_EXIT_999', session.userType)?.text || '';

return {
text,
Expand Down
Loading
Loading