From 9e892c6a5e600c4561aee359bd13d632dddf62d8 Mon Sep 17 00:00:00 2001 From: Mao Nakamoto <41178744+maonakamoto@users.noreply.github.com> Date: Tue, 1 Sep 2026 08:33:35 +0200 Subject: [PATCH] feat(vulnerability): count who needs what, without keeping a register of why MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The City's Eigentümerstrategie 2025-2028 fixes "Berücksichtigung von vulnerablen Personen" as one of six minimum standards in AOZ's Leistungsauftrag, and the Gemeinderat asked specifically for the "systematische Erfassung der Anzahl vulnerabler Geflüchteter". AOZ answered both on paper — a medical vulnerability assessment form in 2025, plus a separate project to record clients "mit eingeschränkten Wohnfähigkeiten sowie insbesondere ältere Geflüchtete" to close "bestehende Angebotslücken im Bereich Wohnen und Wohnbegleitung". None of it was answerable from a system. The obvious implementation is a `vulnerable Boolean` or a `vulnerabilityType` enum on Resident. Both are wrong, for two reasons that point the same way. ETHICALLY: a stored category is applied TO someone and outlives its reason. In this context "vulnerable" reaches for exactly what this product is forbidden to hold — a diagnosis, a violence history, a status. So vulnerability is DERIVED from housing facts the caseworker already recorded because a PLACEMENT needed them: a ground floor, a single room, quiet, space for equipment. Reading them together adds no new fact about the person; it answers a question the City is entitled to ask. STRUCTURALLY: a stored flag is a second copy of state living in seven columns, and the two will drift. Then the reportable figure and the person's own record disagree, with no way to tell which is right. `assessVulnerability` returns the GROUNDS, not a verdict — AOZ has to defend this number, and "the system says 6" is not a defence. Same rule the compatibility scoring already follows: no black-box outcomes. One genuinely missing fact is now storable: `livingSkillsSupport` (Wohnfähigkeit). It is NOT a second `supportLevel` — that is contact FREQUENCY, this is everyday competence with cooking, post, appointments and keeping a tenancy. They come apart in both directions, which is why one cannot stand in for the other, and why AOZ was recording it in a separate project. Defaults to INDEPENDENT so no existing row acquires a need nobody assessed: an unasked question must not read as an answer. Config-driven as CLAUDE.md requires — the factor is one entry in RESIDENT_FACTORS plus a column, and the intake form, the AI field registry and the zod schema all derive from it. The /analytics block reports the total WITH its breakdown, and says out loud that the rows sum to more than the headline because anyone with two needs appears twice — a reader who adds them up would overstate the caseload. Gated by vulnerability.test.ts, which holds the ethical line by DENYLIST: the module may read no field matching diagnos/permit/asylum/trauma/religion, no ground label may name a cause rather than a need, every ground must state a housing implication, and no `vulnerable` column may appear in the schema. Mutation-proven: adding `medicalDocType` to the input or renaming a label to "Gehbehinderung" each fail it. design-system.test.ts caught the org name hardcoded in one ground's copy during development — kept the rewrite, which says what to do rather than who is counting. Verified with SESSION_SECRET=… npm run build (exit 0). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Cd183M6472xBgTKWA2is6h --- .../migration.sql | 28 +++ prisma/schema.prisma | 26 +++ src/app/(admin)/analytics/page.tsx | 13 ++ src/app/(admin)/residents/[id]/edit/page.tsx | 1 + .../analytics/VulnerabilitySection.tsx | 64 ++++++ .../compatibility/__tests__/convert.test.ts | 1 + .../__tests__/placement-scores.test.ts | 1 + src/lib/config/resident-factors.ts | 23 ++ src/lib/constants/labels/index.ts | 1 + src/lib/constants/labels/vulnerability.ts | 28 +++ src/lib/validation/schemas.ts | 3 + .../__tests__/vulnerability.test.ts | 189 ++++++++++++++++ src/lib/vulnerability/index.ts | 205 ++++++++++++++++++ 13 files changed, 583 insertions(+) create mode 100644 prisma/migrations/20260901020000_living_skills_support/migration.sql create mode 100644 src/components/analytics/VulnerabilitySection.tsx create mode 100644 src/lib/constants/labels/vulnerability.ts create mode 100644 src/lib/vulnerability/__tests__/vulnerability.test.ts create mode 100644 src/lib/vulnerability/index.ts diff --git a/prisma/migrations/20260901020000_living_skills_support/migration.sql b/prisma/migrations/20260901020000_living_skills_support/migration.sql new file mode 100644 index 00000000..9d15b555 --- /dev/null +++ b/prisma/migrations/20260901020000_living_skills_support/migration.sql @@ -0,0 +1,28 @@ +-- Wohnfähigkeit: how much help running a household a person needs. +-- +-- The City's owner strategy fixes "Berücksichtigung von vulnerablen Personen" +-- as a minimum standard, and the Gemeinderat asked AOZ specifically for the +-- "systematische Erfassung der Anzahl vulnerabler Geflüchteter". AOZ answered +-- both on paper — a medical vulnerability assessment form in 2025, plus a +-- separate project to record "Klient*innen mit eingeschränkten Wohnfähigkeiten +-- sowie insbesondere ältere Geflüchtete" in order to close "bestehende +-- Angebotslücken im Bereich Wohnen und Wohnbegleitung". +-- +-- Everything in that sentence except Wohnfähigkeit was already recordable here +-- as a functional need. This column is the missing one. +-- +-- It is NOT a second `supportLevel`. That column is contact frequency; this is +-- everyday competence — cooking, cleaning, post, appointments, keeping a +-- tenancy. They come apart in both directions, which is why one cannot stand +-- in for the other. +-- +-- INDEPENDENT is the default so no existing row acquires a support need nobody +-- assessed. An unasked question must not read as an answer. +CREATE TYPE "LivingSkillsSupport" AS ENUM ('INDEPENDENT', 'SOME_SUPPORT', 'REGULAR_SUPPORT'); + +ALTER TABLE "Resident" + ADD COLUMN "livingSkillsSupport" "LivingSkillsSupport" NOT NULL DEFAULT 'INDEPENDENT'; + +-- The reportable figure is a COUNT of people needing more than the default, so +-- the index serves the one query this column exists to answer. +CREATE INDEX "Resident_livingSkillsSupport_idx" ON "Resident"("livingSkillsSupport"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 872c04e7..b84d8b96 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -67,6 +67,17 @@ model Resident { hasSleepEquipment Boolean @default(false) // CPAP, etc. supportLevel SupportLevel @default(STANDARD) + /// How much help running a household this person needs — AOZ's + /// "Wohnfähigkeit". Distinct from `supportLevel`, which is how often someone + /// checks in on them: a person can need weekly contact and still cook, clean + /// and open their post unaided, and another can be seen rarely and still be + /// unable to keep a tenancy. Conflating them is why AOZ was recording this on + /// paper in a separate project rather than reading it off a system. + /// + /// FUNCTIONAL, never a diagnosis: it says what support the household needs, + /// not why. @see lib/vulnerability/ + livingSkillsSupport LivingSkillsSupport @default(INDEPENDENT) + // Roommate preferences (from portal self-service) roommatePreferences String? preferencesCompletedAt DateTime? // Set when portal preferences form is submitted @@ -344,6 +355,21 @@ enum SupportLevel { INTENSIVE // Close support needed } +/// How much support running a household this person needs — AOZ's +/// "Wohnfähigkeit". A capability, never a diagnosis: the values say what help +/// the household needs, and nothing about why it is needed. +/// +/// Deliberately separate from `SupportLevel`. That one is contact FREQUENCY; +/// this is everyday competence with cooking, cleaning, post, appointments and +/// keeping a tenancy. The two come apart in both directions, and AOZ named +/// "Klient*innen mit eingeschränkten Wohnfähigkeiten" as a group it could not +/// find in any system. +enum LivingSkillsSupport { + INDEPENDENT // Runs the household unaided + SOME_SUPPORT // Occasional help: post, appointments, paperwork + REGULAR_SUPPORT // Ongoing accompaniment in daily living +} + enum RecyclingKnowledge { NONE // No experience with Swiss recycling BASIC // Knows basics (paper, glass, PET) diff --git a/src/app/(admin)/analytics/page.tsx b/src/app/(admin)/analytics/page.tsx index 4f1ac02e..46eb78e9 100644 --- a/src/app/(admin)/analytics/page.tsx +++ b/src/app/(admin)/analytics/page.tsx @@ -20,6 +20,8 @@ import { ConflictAnalysisSection } from '@/components/analytics/ConflictAnalysis import { RecentPlacementsTable } from '@/components/analytics/RecentPlacementsTable' import { MissionKPISection } from '@/components/analytics/MissionKPISection' import { AlgorithmAccuracySection } from '@/components/analytics/AlgorithmAccuracySection' +import { VulnerabilitySection } from '@/components/analytics/VulnerabilitySection' +import { summariseVulnerability } from '@/lib/vulnerability' import { calculateMissionKPIs } from '@/lib/analytics/mission-kpis' import { calculateAlgorithmAccuracy } from '@/lib/analytics/algorithm-accuracy' import { getSystemConfig } from '@/lib/actions/config' @@ -159,6 +161,10 @@ export default async function AnalyticsPage({ searchParams }: Props) { const unresolvedIncidents = recentIncidents.filter((i) => !i.resolvedAt) + // Derived from the rows already fetched above — no extra query, and no + // stored flag that could drift from the needs it summarises. + const vulnerability = summariseVulnerability(residents) + // Incident type breakdown (conflicts only) const incidentsByType = recentIncidents.reduce( (acc, i) => { @@ -415,6 +421,13 @@ export default async function AnalyticsPage({ searchParams }: Props) { + {/* Besonderer Unterbringungsbedarf — aggregate only, no one is named, so + it sits at the page's own `dashboard:read` like the other summaries. + @see lib/vulnerability/ for why this is derived and never stored. */} +
+ +
+ {/* Recent Placements — identified resident + satisfaction data, so this follows the same boundary as /placements rather than dashboard:read. */} {canReadPlacements && } diff --git a/src/app/(admin)/residents/[id]/edit/page.tsx b/src/app/(admin)/residents/[id]/edit/page.tsx index c5c055ec..b64b223a 100644 --- a/src/app/(admin)/residents/[id]/edit/page.tsx +++ b/src/app/(admin)/residents/[id]/edit/page.tsx @@ -82,6 +82,7 @@ export default async function EditResidentPage({ params }: Props) { needsQuietEnvironment: resident.needsQuietEnvironment, hasSleepEquipment: resident.hasSleepEquipment, supportLevel: resident.supportLevel, + livingSkillsSupport: resident.livingSkillsSupport, // Medical documentation hasMedicalDocumentation: resident.hasMedicalDocumentation, medicalDocType: resident.medicalDocType, diff --git a/src/components/analytics/VulnerabilitySection.tsx b/src/components/analytics/VulnerabilitySection.tsx new file mode 100644 index 00000000..5e4e58e7 --- /dev/null +++ b/src/components/analytics/VulnerabilitySection.tsx @@ -0,0 +1,64 @@ +import type { VulnerabilitySummary } from '@/lib/vulnerability' +import { VULNERABILITY_LABELS } from '@/lib/constants/labels' + +/** + * The figure the City asks AOZ for, with the reasons attached. + * + * The Gemeinderat asked for the "systematische Erfassung der Anzahl vulnerabler + * Geflüchteter" and the Eigentümerstrategie fixes "Berücksichtigung von + * vulnerablen Personen" as a minimum standard. A bare total would satisfy + * neither: AOZ has to defend the number, and "the system says 6" is not a + * defence. + * + * So the breakdown is not decoration. Three people needing a ground floor is an + * argument about what to procure next; the total alone is not. + */ +export function VulnerabilitySection({ summary }: { summary: VulnerabilitySummary }) { + const share = summary.total > 0 ? Math.round((summary.vulnerable / summary.total) * 100) : 0 + + return ( +
+

{VULNERABILITY_LABELS.eyebrow}

+

{VULNERABILITY_LABELS.title}

+

{VULNERABILITY_LABELS.subtitle}

+ +
+ {summary.vulnerable} + + {VULNERABILITY_LABELS.ofTotal(summary.total)} · {share}% + +
+ + {summary.byGround.length === 0 ? ( +

{VULNERABILITY_LABELS.none}

+ ) : ( + <> +
    + {summary.byGround.map(({ ground, count }) => ( +
  • +
    + {ground.label} + {count} +
    +

    {ground.housingImplication}

    +
  • + ))} +
+ + {/* Said out loud because the rows genuinely do not add to the total — + anyone with two recorded needs appears twice. A reader who sums + them and reports that number would overstate the caseload. */} +

{VULNERABILITY_LABELS.overlapNote}

+ + )} +
+ ) +} diff --git a/src/lib/compatibility/__tests__/convert.test.ts b/src/lib/compatibility/__tests__/convert.test.ts index 5a14a7a6..5b9fbc71 100644 --- a/src/lib/compatibility/__tests__/convert.test.ts +++ b/src/lib/compatibility/__tests__/convert.test.ts @@ -36,6 +36,7 @@ function makePrismaResident(overrides: Partial = {}): Resident { needsQuietEnvironment: false, hasSleepEquipment: false, supportLevel: 'STANDARD', + livingSkillsSupport: 'INDEPENDENT', status: 'ACTIVE', notes: null, hasMedicalDocumentation: false, diff --git a/src/lib/compatibility/__tests__/placement-scores.test.ts b/src/lib/compatibility/__tests__/placement-scores.test.ts index 13db8c75..7e0db06c 100644 --- a/src/lib/compatibility/__tests__/placement-scores.test.ts +++ b/src/lib/compatibility/__tests__/placement-scores.test.ts @@ -54,6 +54,7 @@ function makeResident(overrides: Partial = {}): Resident { needsQuietEnvironment: false, hasSleepEquipment: false, supportLevel: 'STANDARD', + livingSkillsSupport: 'INDEPENDENT', roommatePreferences: null, preferencesCompletedAt: null, status: 'ACTIVE', diff --git a/src/lib/config/resident-factors.ts b/src/lib/config/resident-factors.ts index 933b3860..04a25c5c 100644 --- a/src/lib/config/resident-factors.ts +++ b/src/lib/config/resident-factors.ts @@ -572,6 +572,29 @@ export const RESIDENT_FACTORS: Record = { rule: 'NONE', // For caseworker info, not compatibility }, + livingSkillsSupport: { + id: 'livingSkillsSupport', + type: 'enum', + label: 'Wohnfähigkeit', + description: 'Wie viel Unterstützung braucht diese Person im Haushalt?', + formSection: 'health', + formOrder: 6, + options: ['INDEPENDENT', 'SOME_SUPPORT', 'REGULAR_SUPPORT'] as const, + optionLabels: { + INDEPENDENT: 'Selbstständig', + SOME_SUPPORT: 'Punktuelle Unterstützung (Post, Termine, Ämtli)', + REGULAR_SUPPORT: 'Regelmässige Begleitung im Alltag', + }, + default: 'INDEPENDENT', + dimension: 'requirements', + // Zero weight, like `supportLevel`. This describes what the household needs + // from the Betreuung, not how two people get along — scoring someone down + // as a roommate for needing help with their post would be exactly the + // wrong use of it. + weight: 0, + rule: 'NONE', + }, + // --------------------------------------------------------------------------- // PREFERENCES // --------------------------------------------------------------------------- diff --git a/src/lib/constants/labels/index.ts b/src/lib/constants/labels/index.ts index 8f719d04..58a157f5 100644 --- a/src/lib/constants/labels/index.ts +++ b/src/lib/constants/labels/index.ts @@ -27,3 +27,4 @@ export * from './messages' export * from './marketplace' export * from './events' export * from './opportunities' +export * from './vulnerability' diff --git a/src/lib/constants/labels/vulnerability.ts b/src/lib/constants/labels/vulnerability.ts new file mode 100644 index 00000000..b6a68a1c --- /dev/null +++ b/src/lib/constants/labels/vulnerability.ts @@ -0,0 +1,28 @@ +/** + * Vulnerability reporting — staff-facing German. + * + * The wording is doing real work here, so it is worth stating the rule it + * follows: this surface names NEEDS, never causes. It reports "besonderer + * Unterbringungsbedarf", not "vulnerable Personen" as a class of people — + * because the product derives the figure from housing facts and must not sound + * like it holds a register of conditions, which it deliberately does not. + * + * @see lib/vulnerability/ for why the assessment is computed, never stored. + */ +export const VULNERABILITY_LABELS = { + eyebrow: 'Unterbringungsbedarf', + title: 'Klient*innen mit besonderem Bedarf', + subtitle: + 'Erfasst aus den festgehaltenen Wohnbedürfnissen — nicht aus Diagnosen. Jede Person zählt einmal, unabhängig davon, wie viele Bedürfnisse erfasst sind.', + /** Reads as "von 19 aktiven Klient*innen". */ + ofTotal: (total: number) => + total === 1 ? 'von 1 aktiven Klient*in' : `von ${total} aktiven Klient*innen`, + none: 'Für keine aktive Person ist ein besonderer Unterbringungsbedarf erfasst.', + /** + * The rows genuinely do not sum to the headline — someone with two recorded + * needs appears in two of them. Saying so prevents a reader from adding the + * column up and reporting a caseload larger than it is. + */ + overlapNote: + 'Mehrfachnennung möglich: Wer mehrere Bedürfnisse hat, erscheint in mehreren Zeilen. Die Zeilen ergeben deshalb mehr als die Gesamtzahl oben.', +} as const diff --git a/src/lib/validation/schemas.ts b/src/lib/validation/schemas.ts index 1c9fac3e..0d894c24 100644 --- a/src/lib/validation/schemas.ts +++ b/src/lib/validation/schemas.ts @@ -20,6 +20,7 @@ import type { MobilityNeed, RoomSharingStatus, SupportLevel, + LivingSkillsSupport, RecyclingKnowledge, MedicalDocType, ResidentStatus, @@ -120,6 +121,7 @@ export const SmokingStatusSchema = enumFromFactor('smokingStatus' export const MobilityNeedSchema = enumFromFactor('mobilityNeeds') export const RoomSharingStatusSchema = enumFromFactor('roomSharingStatus') export const SupportLevelSchema = enumFromFactor('supportLevel') +export const LivingSkillsSupportSchema = enumFromFactor('livingSkillsSupport') export const RecyclingKnowledgeSchema = enumFromFactor('recyclingKnowledge') // Spot/placement - derived from placement-spots config @@ -208,6 +210,7 @@ export const ResidentInputSchema = z.object({ needsQuietEnvironment: z.coerce.boolean().default(false), hasSleepEquipment: z.coerce.boolean().default(false), supportLevel: SupportLevelSchema.default('STANDARD' as SupportLevel), + livingSkillsSupport: LivingSkillsSupportSchema.default('INDEPENDENT' as LivingSkillsSupport), hasMedicalDocumentation: z.coerce.boolean().default(false), medicalDocType: MedicalDocTypeSchema.optional().nullable(), medicalDocDate: z diff --git a/src/lib/vulnerability/__tests__/vulnerability.test.ts b/src/lib/vulnerability/__tests__/vulnerability.test.ts new file mode 100644 index 00000000..a9f82b3f --- /dev/null +++ b/src/lib/vulnerability/__tests__/vulnerability.test.ts @@ -0,0 +1,189 @@ +import fs from 'fs' +import path from 'path' +import { + VULNERABILITY_GROUNDS, + VULNERABILITY_GROUND_IDS, + assessVulnerability, + summariseVulnerability, + type VulnerabilityInput, +} from '../index' + +/** + * The count the City asks for, and the line it must not cross to produce it. + */ + +const independent: VulnerabilityInput = { + ageRange: 'ADULT', + mobilityNeeds: 'NONE', + medicalEquipment: false, + hasSleepEquipment: false, + roomSharingStatus: 'CAN_SHARE', + needsQuietEnvironment: false, + supportLevel: 'STANDARD', + livingSkillsSupport: 'INDEPENDENT', +} + +const withNeed = (over: Partial): VulnerabilityInput => ({ + ...independent, + ...over, +}) + +describe('who counts, and why', () => { + it('a person with no recorded need beyond the ordinary does not count', () => { + const result = assessVulnerability(independent) + expect(result.isVulnerable).toBe(false) + expect(result.grounds).toEqual([]) + }) + + it.each([ + ['MOBILITY', { mobilityNeeds: 'GROUND_FLOOR' as const }], + ['MOBILITY', { mobilityNeeds: 'WHEELCHAIR' as const }], + ['CANNOT_SHARE', { roomSharingStatus: 'NEEDS_PRIVATE' as const }], + ['QUIET_ENVIRONMENT', { needsQuietEnvironment: true }], + ['EQUIPMENT', { medicalEquipment: true }], + ['EQUIPMENT', { hasSleepEquipment: true }], + ['ELEVATED_SUPPORT', { supportLevel: 'ELEVATED' as const }], + ['ELEVATED_SUPPORT', { supportLevel: 'INTENSIVE' as const }], + ['LIVING_SKILLS', { livingSkillsSupport: 'REGULAR_SUPPORT' as const }], + ['OLDER_ADULT', { ageRange: 'SENIOR' as const }], + ])('%s is raised by the fact that justifies it', (groundId, over) => { + const result = assessVulnerability(withNeed(over)) + expect(result.isVulnerable).toBe(true) + expect(result.grounds.map((g) => g.id)).toContain(groundId) + }) + + it('names every ground that holds, not just the first', () => { + // The breakdown is the point: "6 people" is a number, "3 of them need a + // ground floor" is what tells AOZ what to procure next. + const result = assessVulnerability( + withNeed({ + mobilityNeeds: 'WHEELCHAIR', + roomSharingStatus: 'NEEDS_PRIVATE', + ageRange: 'SENIOR', + }), + ) + expect(result.grounds.map((g) => g.id).sort()).toEqual( + ['CANNOT_SHARE', 'MOBILITY', 'OLDER_ADULT'].sort(), + ) + }) + + it('reports occasional household help as NOT on its own a vulnerability', () => { + // SOME_SUPPORT is help with post and appointments. Counting that as + // vulnerable would inflate the figure AOZ has to defend, and would tell a + // caseworker something untrue about the person. + expect( + assessVulnerability(withNeed({ livingSkillsSupport: 'SOME_SUPPORT' })).isVulnerable, + ).toBe(false) + expect( + assessVulnerability(withNeed({ livingSkillsSupport: 'REGULAR_SUPPORT' })).isVulnerable, + ).toBe(true) + }) + + it('every declared ground id has a definition, and vice versa', () => { + expect(VULNERABILITY_GROUNDS.map((g) => g.id).sort()).toEqual( + [...VULNERABILITY_GROUND_IDS].sort(), + ) + }) +}) + +describe('the reportable figure', () => { + it('counts people once, however many grounds they hold', () => { + const summary = summariseVulnerability([ + independent, + withNeed({ mobilityNeeds: 'WHEELCHAIR', roomSharingStatus: 'NEEDS_PRIVATE' }), + withNeed({ ageRange: 'SENIOR' }), + ]) + + expect(summary.total).toBe(3) + expect(summary.vulnerable).toBe(2) + }) + + it('breaks the total down by ground, and the parts may exceed the whole', () => { + // Deliberate: one person with two needs appears in two rows. The UI must + // say so rather than invite the reader to add them up. + const summary = summariseVulnerability([ + withNeed({ mobilityNeeds: 'WHEELCHAIR', needsQuietEnvironment: true }), + ]) + + expect(summary.vulnerable).toBe(1) + const counted = summary.byGround.reduce((sum, row) => sum + row.count, 0) + expect(counted).toBe(2) + }) + + it('omits grounds that hold for nobody rather than listing zeroes', () => { + const summary = summariseVulnerability([withNeed({ ageRange: 'SENIOR' })]) + expect(summary.byGround).toHaveLength(1) + expect(summary.byGround[0].ground.id).toBe('OLDER_ADULT') + }) + + it('an empty register reports zero, not an error', () => { + expect(summariseVulnerability([])).toEqual({ total: 0, vulnerable: 0, byGround: [] }) + }) +}) + +describe('the line this module must not cross', () => { + /** + * CLAUDE.md: track functional needs, never diagnoses, immigration status, + * religion or personal history. "Vulnerable" in the asylum context reaches + * for exactly those, so the safeguard cannot be a good intention in a + * comment — the reason this is DERIVED from housing facts rather than stored + * as a label is that a label would be the forbidden thing. + */ + const SOURCE = fs.readFileSync(path.join(__dirname, '..', 'index.ts'), 'utf8') + const code = SOURCE.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1') + + it('reads no field describing a condition, a history or a status', () => { + // Not an allowlist of what it may read — a denylist of what it may never + // read, which is the half that catches the field nobody thought of. + const FORBIDDEN = [ + 'diagnos', + 'medicalDocNotes', + 'medicalDocType', + 'religion', + 'permit', + 'asylum', + 'residencyStatus', + 'trauma', + 'pregnan', + 'disabilit', + ] + for (const term of FORBIDDEN) { + expect({ term, present: new RegExp(term, 'i').test(code) }).toEqual({ term, present: false }) + } + }) + + it('no ground label names a cause rather than a need', () => { + // "Braucht Erdgeschoss" is a need. "Gehbehinderung" would be a condition. + const CAUSE_WORDS = /krank|diagnos|behinderung|trauma|psychisch|sucht|schwanger/i + for (const ground of VULNERABILITY_GROUNDS) { + expect({ id: ground.id, namesCause: CAUSE_WORDS.test(ground.label) }).toEqual({ + id: ground.id, + namesCause: false, + }) + expect({ + id: ground.id, + namesCause: CAUSE_WORDS.test(ground.housingImplication), + }).toEqual({ id: ground.id, namesCause: false }) + } + }) + + it('every ground explains what it means for placement', () => { + // A ground with no housing implication is a label on a person, which is + // the thing this module exists to avoid being. + for (const ground of VULNERABILITY_GROUNDS) { + expect(ground.housingImplication.trim().length).toBeGreaterThan(10) + } + }) + + it('stores nothing — the verdict is computed on every read', () => { + // If a `vulnerable` column ever appears, it is a second copy of state that + // will drift from the seven columns it summarises, and the reportable + // figure and the person's record will disagree with no way to tell which + // is right. + const schema = fs.readFileSync( + path.join(__dirname, '..', '..', '..', '..', 'prisma', 'schema.prisma'), + 'utf8', + ) + expect(schema).not.toMatch(/^\s*(isVulnerable|vulnerable|vulnerabilityType)\s/m) + }) +}) diff --git a/src/lib/vulnerability/index.ts b/src/lib/vulnerability/index.ts new file mode 100644 index 00000000..708db386 --- /dev/null +++ b/src/lib/vulnerability/index.ts @@ -0,0 +1,205 @@ +/** + * Vulnerability — DERIVED from housing-relevant facts, never a label on a person. + * + * The City of Zurich's Eigentümerstrategie 2025–2028 fixes "Berücksichtigung + * von vulnerablen Personen" as one of six minimum standards in AOZ's + * Leistungsauftrag, and the Gemeinderat asked AOZ specifically for the + * "systematische Erfassung der Anzahl vulnerabler Geflüchteter". So the number + * has to be answerable. The question is how. + * + * ## Why this is computed and not stored + * + * The obvious implementation is a `vulnerable Boolean` or a + * `vulnerabilityType` enum on Resident. Both are wrong here, for two separate + * reasons that happen to point the same way. + * + * **Ethically:** a stored category is applied TO someone and outlives the + * reason for it. In the Swiss asylum context "vulnerable" reaches for things + * this product is forbidden to hold — a diagnosis, a torture or violence + * history, a status. CLAUDE.md's rule is absolute and worth restating: track + * functional needs, never why they exist. Every ground below is a fact the + * caseworker already recorded because a HOUSING decision needed it — a ground + * floor, a single room, quiet. Reading them together adds no new fact about + * the person; it just answers a question the City is entitled to ask. + * + * **Structurally:** a stored flag is a second copy of state that already + * exists in seven columns, and the two WILL drift. Someone's mobility need is + * corrected and the flag stays; the flag is set by hand and no column supports + * it. Then the reportable figure and the person's record disagree, and there + * is no way to tell which is right. + * + * ## Explainable, because a number nobody can defend is worthless + * + * `assess()` returns the GROUNDS, not just a verdict. AOZ has to defend this + * count to the Gemeinderat, and "the system says 6" is not a defence — "6, + * because these three need a ground floor, two cannot share a room and one + * needs regular help running the household" is. Same rule the compatibility + * scoring already follows: no black-box outcomes. + * + * Adding a ground is a line in `VULNERABILITY_GROUNDS` plus its predicate. It + * must be a functional housing fact already on the record. If a proposed + * ground would need a new column describing a person's history, condition or + * status, it does not belong here and probably does not belong in the product. + */ + +import type { + AgeRange, + LivingSkillsSupport, + MobilityNeed, + RoomSharingStatus, + SupportLevel, +} from '@prisma/client' + +/** + * The subset of a resident this reads. Deliberately narrow and explicit: the + * type is the audit trail. If a diagnosis-shaped field ever appears on + * Resident, adding it here would be a visible, reviewable act rather than + * something that arrives by spreading the whole model. + */ +export interface VulnerabilityInput { + ageRange: AgeRange + mobilityNeeds: MobilityNeed + medicalEquipment: boolean + hasSleepEquipment: boolean + roomSharingStatus: RoomSharingStatus + needsQuietEnvironment: boolean + supportLevel: SupportLevel + livingSkillsSupport: LivingSkillsSupport +} + +export const VULNERABILITY_GROUND_IDS = [ + 'MOBILITY', + 'CANNOT_SHARE', + 'QUIET_ENVIRONMENT', + 'EQUIPMENT', + 'ELEVATED_SUPPORT', + 'LIVING_SKILLS', + 'OLDER_ADULT', +] as const + +export type VulnerabilityGroundId = (typeof VULNERABILITY_GROUND_IDS)[number] + +export interface VulnerabilityGround { + id: VulnerabilityGroundId + /** German, staff-facing. Says the NEED, never the cause. */ + label: string + /** What this implies for placement — the reason it is worth knowing. */ + housingImplication: string + holds: (input: VulnerabilityInput) => boolean +} + +/** + * SSOT for what counts, in the order a caseworker would read them: the hard + * placement constraints first, then the support ones. + * + * Every label describes a NEED. None names a condition. That is the line, and + * `vulnerability.test.ts` holds it. + */ +export const VULNERABILITY_GROUNDS: readonly VulnerabilityGround[] = [ + { + id: 'MOBILITY', + label: 'Eingeschränkte Mobilität', + housingImplication: 'Braucht Erdgeschoss oder rollstuhlgerechte Unterkunft.', + holds: (r) => r.mobilityNeeds !== 'NONE', + }, + { + id: 'CANNOT_SHARE', + label: 'Kann kein Zimmer teilen', + housingImplication: 'Braucht ein Einzelzimmer.', + holds: (r) => r.roomSharingStatus === 'NEEDS_PRIVATE', + }, + { + id: 'QUIET_ENVIRONMENT', + label: 'Braucht ruhiges Umfeld', + housingImplication: 'Nicht in eine laute oder stark belegte Einheit platzieren.', + holds: (r) => r.needsQuietEnvironment, + }, + { + id: 'EQUIPMENT', + label: 'Benötigt Platz für Geräte', + housingImplication: 'Braucht Platz und Strom am Schlafplatz.', + holds: (r) => r.medicalEquipment || r.hasSleepEquipment, + }, + { + id: 'ELEVATED_SUPPORT', + label: 'Erhöhter Betreuungsbedarf', + housingImplication: 'Häufigere Check-ins als der Standard.', + holds: (r) => r.supportLevel !== 'STANDARD', + }, + { + id: 'LIVING_SKILLS', + label: 'Begleitung im Haushalt nötig', + housingImplication: 'Braucht regelmässige Unterstützung im Alltag.', + holds: (r) => r.livingSkillsSupport === 'REGULAR_SUPPORT', + }, + { + id: 'OLDER_ADULT', + label: 'Ältere Person', + // Was "AOZ erfasst ältere Geflüchtete als eigene Bedarfsgruppe" — caught by + // design-system.test.ts, which forbids the org name in UI copy so a + // re-badge cannot leave the wrong organisation's name on a screen. The + // rewrite is better regardless: every other ground tells a caseworker what + // to DO, and that one only said who was counting. + housingImplication: 'Barrierefreiheit und Nähe zur Versorgung prüfen.', + holds: (r) => r.ageRange === 'SENIOR', + }, +] + +export interface VulnerabilityAssessment { + isVulnerable: boolean + grounds: VulnerabilityGround[] +} + +/** + * Which grounds hold for this person, and therefore whether they count. + * + * "Vulnerable" here means exactly "at least one recorded housing need beyond + * the ordinary" — no more and no less. It is a reading of the record, not a + * judgement about the person, and it changes the moment the record does. + */ +export function assessVulnerability(input: VulnerabilityInput): VulnerabilityAssessment { + const grounds = VULNERABILITY_GROUNDS.filter((ground) => ground.holds(input)) + return { isVulnerable: grounds.length > 0, grounds } +} + +/** + * The reportable figure: how many people, and how the total breaks down. + * + * The breakdown matters as much as the total. A count of 6 is a number; 6 of + * whom 3 need a ground floor is an argument for what to procure next, which is + * what the Gemeinderat is actually asking for. + * + * `byGround` sums to MORE than `vulnerable` whenever anyone holds two grounds. + * That is correct and deliberate — they answer different questions — and the + * label in the UI must say so rather than inviting the reader to add them up. + */ +export interface VulnerabilitySummary { + total: number + vulnerable: number + byGround: { ground: VulnerabilityGround; count: number }[] +} + +export function summariseVulnerability(residents: VulnerabilityInput[]): VulnerabilitySummary { + const assessments = residents.map(assessVulnerability) + + return { + total: residents.length, + vulnerable: assessments.filter((a) => a.isVulnerable).length, + byGround: VULNERABILITY_GROUNDS.map((ground) => ({ + ground, + count: assessments.filter((a) => a.grounds.some((g) => g.id === ground.id)).length, + })).filter((row) => row.count > 0), + } +} + +/** Everything a vulnerability read needs, for `select:` in a Prisma query. */ +export const VULNERABILITY_SELECT = { + ageRange: true, + mobilityNeeds: true, + medicalEquipment: true, + hasSleepEquipment: true, + roomSharingStatus: true, + needsQuietEnvironment: true, + supportLevel: true, + livingSkillsSupport: true, +} as const