Skip to content

Commit 5a5ca96

Browse files
RJK134claude
andauthored
feat(ukvi): Phase 19C — UKVI attendance/compliance escalation workflow (#235)
Third batch of Phase 19 (Statutory and Regulatory Execution). Completes the UKVI sponsor-duty escalation workflow on top of the existing UKVI CRUD surface and attendance-monitoring models. 1. Pure decision utility `utils/ukvi-escalation.ts::decideUkviEscalation`. Given a sponsored student's compliance status, visa status, rolling attendance-monitoring snapshot history and existing reports, decides whether to escalate, the recommended compliance status, and which UKVIReportType to raise. Encodes the COMPLIANT → AT_RISK → NON_COMPLIANT → REPORTED ladder by consecutive non-compliant snapshots; selects NO_SHOW vs NON_COMPLIANCE by run severity; suppresses duplicate reports; short-circuits non-sponsored and already-REPORTED records. Same purity contract as 17A/17D/17E/18A-D/ 19A/19B utilities — no Prisma, no I/O. Rule overrides + clamping. 2. Service orchestration in `ukvi.service.ts`: - evaluateEscalation(recordId, options, userId, req) — loads the record (with monitoring history + reports), runs the pure utility, and in persist mode raises a draft UKVIReport and promotes the compliance status (routing the status change through update() so the existing ukvi.compliance_changed event fires). Preview is the default. Always emits ukvi.escalation_evaluated. - submitReport / acknowledgeReport — the draft → submitted → acknowledged report lifecycle, mirroring the 19B HESA-return lifecycle pattern. acknowledgeReport stamps the Home Office ref and promotes the parent record to REPORTED. State-precondition guards with force overrides. - recordContactPoint — records an engagement-monitoring touchpoint (the sponsor's retained evidence) with audit + event. 3. Repo helpers in compliance.repository.ts: getReportById, updateReport, getContactPointById. 4. Endpoints (all on the existing ukviRouter, COMPLIANCE-gated, so the router count stays 57): - POST /v1/ukvi/contact-points - POST /v1/ukvi/:id/evaluate-escalation - POST /v1/ukvi/reports/:id/submit - POST /v1/ukvi/reports/:id/acknowledge 5. Webhooks: ukvi.escalation_evaluated, ukvi.contact_point_recorded, ukvi.report_created, ukvi.report_submitted, ukvi.report_acknowledged. 6. Tests: - ukvi-escalation.test.ts (new) — 19 pure-function cases. - ukvi.service.test.ts (new) — 16 service-orchestration cases. Verification - Server tsc: clean (EXIT 0). - prisma validate: clean. - Server Vitest: 808/808 passing across 46 files (was 773/44 on main; +19 pure + 16 service). - ESLint of new source files: 0 errors, 0 warnings. - docs-truth: all checks pass; router count unchanged at 57. - Gate 4 (no direct Prisma in services): the service routes through the compliance repository, not the Prisma client. Deliberately out-of-scope (later 19 batches) - EC claims + appeals downstream actions / reporting — 19D. - Compliance dashboards, evidence trails, closeout — 19E. - Home Office SMS XML export of the UKVIReport set — a future batch alongside the 19B HESA export pipeline (the report lifecycle and status model are in place; the wire format is deferred). - Scheduled attendance-monitoring snapshot generation that feeds the escalation evaluator — sequenced to Phase 20 (n8n scheduled jobs). Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7525217 commit 5a5ca96

9 files changed

Lines changed: 1299 additions & 2 deletions

File tree

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
/**
2+
* Phase 19C — pure-function tests for `utils/ukvi-escalation`.
3+
*
4+
* Covers the sponsor-duty escalation ladder: non-sponsored short-circuit,
5+
* already-reported short-circuit, the COMPLIANT → AT_RISK → NON_COMPLIANT
6+
* progression by consecutive non-compliant snapshots, NO_SHOW vs
7+
* NON_COMPLIANCE report-type selection, duplicate-report suppression,
8+
* rule overrides + clamping, snapshot ordering, and determinism.
9+
*/
10+
11+
import { describe, it, expect } from 'vitest';
12+
import {
13+
decideUkviEscalation,
14+
DEFAULT_UKVI_ESCALATION_RULES,
15+
type UkviEscalationInput,
16+
type AttendanceSnapshotForEscalation,
17+
} from '../../utils/ukvi-escalation';
18+
19+
const snap = (
20+
monitoringDate: string,
21+
attendancePercentage: number,
22+
compliant: boolean,
23+
): AttendanceSnapshotForEscalation => ({ monitoringDate, attendancePercentage, compliant });
24+
25+
const base = (overrides: Partial<UkviEscalationInput> = {}): UkviEscalationInput => ({
26+
tier4Status: 'SPONSORED',
27+
complianceStatus: 'COMPLIANT',
28+
attendanceMonitoring: [],
29+
existingReports: [],
30+
...overrides,
31+
});
32+
33+
describe('decideUkviEscalation — out-of-scope short-circuits', () => {
34+
it('does not escalate a NOT_SPONSORED student', () => {
35+
const out = decideUkviEscalation(base({ tier4Status: 'NOT_SPONSORED' }));
36+
expect(out.shouldEscalate).toBe(false);
37+
expect(out.recommendedStatus).toBe('COMPLIANT');
38+
expect(out.recommendedReportType).toBeNull();
39+
expect(out.reasons[0]).toContain('not SPONSORED');
40+
});
41+
42+
it('does not escalate a PENDING visa student', () => {
43+
const out = decideUkviEscalation(base({ tier4Status: 'PENDING' }));
44+
expect(out.shouldEscalate).toBe(false);
45+
expect(out.riskLevel).toBe('GREEN');
46+
});
47+
48+
it('does not re-escalate an already-REPORTED record', () => {
49+
const out = decideUkviEscalation(
50+
base({
51+
complianceStatus: 'REPORTED',
52+
attendanceMonitoring: [snap('2026-03-01', 0, false), snap('2026-02-01', 0, false)],
53+
}),
54+
);
55+
expect(out.shouldEscalate).toBe(false);
56+
expect(out.recommendedStatus).toBe('REPORTED');
57+
expect(out.recommendedReportType).toBeNull();
58+
expect(out.riskLevel).toBe('RED');
59+
});
60+
61+
it('returns COMPLIANT when there are no snapshots', () => {
62+
const out = decideUkviEscalation(base());
63+
expect(out.shouldEscalate).toBe(false);
64+
expect(out.recommendedStatus).toBe('COMPLIANT');
65+
expect(out.consecutiveNonCompliant).toBe(0);
66+
});
67+
});
68+
69+
describe('decideUkviEscalation — compliant case', () => {
70+
it('stays COMPLIANT when the most recent snapshot is within threshold', () => {
71+
const out = decideUkviEscalation(
72+
base({
73+
attendanceMonitoring: [snap('2026-03-01', 90, true), snap('2026-02-01', 50, false)],
74+
}),
75+
);
76+
expect(out.recommendedStatus).toBe('COMPLIANT');
77+
expect(out.consecutiveNonCompliant).toBe(0);
78+
expect(out.shouldEscalate).toBe(false);
79+
});
80+
});
81+
82+
describe('decideUkviEscalation — AT_RISK band', () => {
83+
it('moves to AT_RISK after one consecutive non-compliant snapshot', () => {
84+
const out = decideUkviEscalation(
85+
base({
86+
attendanceMonitoring: [snap('2026-03-01', 55, false), snap('2026-02-01', 90, true)],
87+
}),
88+
);
89+
expect(out.recommendedStatus).toBe('AT_RISK');
90+
expect(out.recommendedReportType).toBeNull();
91+
expect(out.riskLevel).toBe('AMBER');
92+
expect(out.consecutiveNonCompliant).toBe(1);
93+
expect(out.shouldEscalate).toBe(true); // status moves COMPLIANT → AT_RISK
94+
});
95+
96+
it('does not re-flag escalation when already AT_RISK at the same band', () => {
97+
const out = decideUkviEscalation(
98+
base({
99+
complianceStatus: 'AT_RISK',
100+
attendanceMonitoring: [snap('2026-03-01', 55, false), snap('2026-02-01', 90, true)],
101+
}),
102+
);
103+
expect(out.recommendedStatus).toBe('AT_RISK');
104+
expect(out.shouldEscalate).toBe(false);
105+
});
106+
});
107+
108+
describe('decideUkviEscalation — NON_COMPLIANT + reportable event', () => {
109+
it('moves to NON_COMPLIANT and recommends NON_COMPLIANCE after two consecutive', () => {
110+
const out = decideUkviEscalation(
111+
base({
112+
attendanceMonitoring: [snap('2026-03-01', 40, false), snap('2026-02-01', 45, false)],
113+
}),
114+
);
115+
expect(out.recommendedStatus).toBe('NON_COMPLIANT');
116+
expect(out.recommendedReportType).toBe('NON_COMPLIANCE');
117+
expect(out.riskLevel).toBe('RED');
118+
expect(out.consecutiveNonCompliant).toBe(2);
119+
expect(out.shouldEscalate).toBe(true);
120+
});
121+
122+
it('recommends NO_SHOW when the run is total disengagement (0%)', () => {
123+
const out = decideUkviEscalation(
124+
base({
125+
attendanceMonitoring: [snap('2026-03-01', 0, false), snap('2026-02-01', 0, false)],
126+
}),
127+
);
128+
expect(out.recommendedStatus).toBe('NON_COMPLIANT');
129+
expect(out.recommendedReportType).toBe('NO_SHOW');
130+
});
131+
132+
it('counts only the most recent consecutive run, broken by a compliant snapshot', () => {
133+
const out = decideUkviEscalation(
134+
base({
135+
attendanceMonitoring: [
136+
snap('2026-03-01', 40, false),
137+
snap('2026-02-15', 90, true), // breaks the run
138+
snap('2026-02-01', 30, false),
139+
],
140+
}),
141+
);
142+
expect(out.consecutiveNonCompliant).toBe(1);
143+
expect(out.recommendedStatus).toBe('AT_RISK');
144+
});
145+
146+
it('suppresses a duplicate report when a non-draft report of the same type exists', () => {
147+
const out = decideUkviEscalation(
148+
base({
149+
attendanceMonitoring: [snap('2026-03-01', 40, false), snap('2026-02-01', 45, false)],
150+
existingReports: [{ reportType: 'NON_COMPLIANCE', reportDate: '2026-02-20', status: 'submitted' }],
151+
}),
152+
);
153+
expect(out.recommendedStatus).toBe('NON_COMPLIANT');
154+
expect(out.recommendedReportType).toBeNull();
155+
expect(out.reasons.some((r) => r.includes('already exists'))).toBe(true);
156+
});
157+
158+
it('still recommends a report when only a draft of the same type exists', () => {
159+
const out = decideUkviEscalation(
160+
base({
161+
attendanceMonitoring: [snap('2026-03-01', 40, false), snap('2026-02-01', 45, false)],
162+
existingReports: [{ reportType: 'NON_COMPLIANCE', reportDate: '2026-02-20', status: 'draft' }],
163+
}),
164+
);
165+
expect(out.recommendedReportType).toBe('NON_COMPLIANCE');
166+
});
167+
});
168+
169+
describe('decideUkviEscalation — snapshot ordering', () => {
170+
it('sorts unordered snapshots most-recent-first before evaluating', () => {
171+
const out = decideUkviEscalation(
172+
base({
173+
// deliberately oldest-first / shuffled
174+
attendanceMonitoring: [
175+
snap('2026-01-01', 30, false),
176+
snap('2026-03-01', 95, true),
177+
snap('2026-02-01', 40, false),
178+
],
179+
}),
180+
);
181+
// most recent (2026-03-01) is compliant → COMPLIANT
182+
expect(out.recommendedStatus).toBe('COMPLIANT');
183+
expect(out.consecutiveNonCompliant).toBe(0);
184+
});
185+
186+
it('falls back to percentage-vs-threshold when the compliant flag is non-boolean', () => {
187+
const out = decideUkviEscalation(
188+
base({
189+
attendanceMonitoring: [
190+
{ monitoringDate: '2026-03-01', attendancePercentage: 50, compliant: undefined as unknown as boolean },
191+
{ monitoringDate: '2026-02-01', attendancePercentage: 50, compliant: undefined as unknown as boolean },
192+
],
193+
}),
194+
);
195+
// 50% < 70% default threshold → both non-compliant → NON_COMPLIANT
196+
expect(out.recommendedStatus).toBe('NON_COMPLIANT');
197+
expect(out.consecutiveNonCompliant).toBe(2);
198+
});
199+
});
200+
201+
describe('decideUkviEscalation — rule overrides', () => {
202+
it('honours a custom report threshold', () => {
203+
const out = decideUkviEscalation(
204+
base({
205+
attendanceMonitoring: [snap('2026-03-01', 40, false), snap('2026-02-01', 45, false), snap('2026-01-01', 40, false)],
206+
rules: { nonCompliantConsecutive: 3 },
207+
}),
208+
);
209+
expect(out.consecutiveNonCompliant).toBe(3);
210+
expect(out.recommendedStatus).toBe('NON_COMPLIANT');
211+
expect(out.effectiveRules.nonCompliantConsecutive).toBe(3);
212+
});
213+
214+
it('honours a custom attendance threshold', () => {
215+
const out = decideUkviEscalation(
216+
base({
217+
attendanceMonitoring: [snap('2026-03-01', 80, true), snap('2026-02-01', 80, true)],
218+
rules: { attendanceThreshold: 85 },
219+
}),
220+
);
221+
// stored compliant flag is true, so the flag wins regardless of threshold
222+
expect(out.recommendedStatus).toBe('COMPLIANT');
223+
});
224+
225+
it('clamps a broken nonCompliantConsecutive below atRiskConsecutive back to a sane value', () => {
226+
const out = decideUkviEscalation(
227+
base({
228+
attendanceMonitoring: [snap('2026-03-01', 40, false)],
229+
rules: { atRiskConsecutive: 2, nonCompliantConsecutive: 1 },
230+
}),
231+
);
232+
// nonCompliantConsecutive clamped up to >= atRiskConsecutive
233+
expect(out.effectiveRules.nonCompliantConsecutive).toBeGreaterThanOrEqual(
234+
out.effectiveRules.atRiskConsecutive,
235+
);
236+
});
237+
238+
it('clamps a negative attendanceThreshold back to default', () => {
239+
const out = decideUkviEscalation(base({ rules: { attendanceThreshold: -5 } }));
240+
expect(out.effectiveRules.attendanceThreshold).toBe(DEFAULT_UKVI_ESCALATION_RULES.attendanceThreshold);
241+
});
242+
});
243+
244+
describe('decideUkviEscalation — determinism', () => {
245+
it('returns identical output across repeated runs', () => {
246+
const input = base({
247+
attendanceMonitoring: [snap('2026-03-01', 40, false), snap('2026-02-01', 45, false)],
248+
});
249+
expect(decideUkviEscalation(input)).toEqual(decideUkviEscalation(input));
250+
});
251+
});

0 commit comments

Comments
 (0)