From b4672e251735eb7251764a01608b46c7ae9a0f25 Mon Sep 17 00:00:00 2001 From: Mao Nakamoto <41178744+maonakamoto@users.noreply.github.com> Date: Tue, 1 Sep 2026 09:51:32 +0200 Subject: [PATCH] feat(complaints): a channel that points at the organisation, not at the resident MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The report form has offered exactly two destinations: a dripping tap to the maintenance board, a roommate conflict to the incident ladder. An objection to how AOZ itself acted fitted neither — and filing one as an Incident would have been worse than dropping it, because that ladder escalates TOWARD a resident and ends in FORMAL_MEASURE. Complaining about staff would have opened a case against the person complaining. The City's Eigentümerstrategie 2025-2028 fixes "Information und Beschwerdestellen" as one of six minimum standards in AOZ's Leistungsauftrag. AOZ's own central Beschwerdestelle logged 88 complaints in 2023, 145 in 2024 and 242 in 2025 — 38% of the last figure about Unterbringung und Zusammenleben, while the client count stayed flat. The product had no side of that obligation at all. THE BOUNDARY IS THE FEATURE. `complaints:read` and `complaints:respond` are held by no care role, and — the part that matters — are NOT widened by ALL_DOMAINS. Every other permission grows with oversight, because seeing every domain is the point of that axis. These must not: the person with oversight over every care domain is one of the people a complaint can be ABOUT. A grievance channel whose reader may be its subject is not a grievance channel. They are named separately from SYSTEM_ADMIN_PERMISSIONS even though isSystemAdmin grants both today, because configuring the product and hearing a complaint against staff are different jobs. AOZ's Beschwerdestelle is deliberately independent of the site team; when a real person fills that seat they should get these two verbs without also being handed the settings page, which is the whole lesson of retiring ADMIN. Anonymity is offered, with its cost stated where the choice is made: no name means nobody can answer, and it will not appear under "Deine Meldungen". `residentId` is nullable and the null IS the anonymity — there is nothing to redact later because nothing was written. The route calls no logAudit for the same reason: an audit row naming the reporter would quietly undo the promise. Resident-facing copy is translated into all six offered locales. The first version used the staff German constants and three portal gates caught it, rightly — a complaints channel that only speaks German is close to useless for the people it exists to protect, who are the least likely in the building to read it. The i18n script gate then caught a Ukrainian apostrophe (U+02BC where that dictionary uses U+2019), which is the same sibling-language class as the `ru` file that was one-third Ukrainian. Gated by complaint-boundary.test.ts. Mutation-proven: removing the early return in `hasPermission` and granting `complaints:read` to BETREUUNG — the exact "tidy up the special case" edit someone will eventually attempt — fails three checks. Verified with SESSION_SECRET=… npm run build (exit 0); all three routes compile. Contains a migration. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Cd183M6472xBgTKWA2is6h --- .../migration.sql | 52 +++++++ prisma/schema.prisma | 64 ++++++++ src/app/(admin)/complaints/page.tsx | 111 ++++++++++++++ src/app/api/portal/complaints/route.ts | 83 +++++++++++ src/app/portal/complaints/ComplaintForm.tsx | 139 ++++++++++++++++++ src/app/portal/complaints/page.tsx | 67 +++++++++ src/lib/actions/complaints.ts | 45 ++++++ .../auth/__tests__/complaint-boundary.test.ts | 102 +++++++++++++ src/lib/auth/role-policy.ts | 31 +++- src/lib/auth/route-boundaries.ts | 3 + src/lib/config/navigation.ts | 7 + src/lib/config/permission-descriptions.ts | 2 + src/lib/constants/labels/complaints.ts | 71 +++++++++ src/lib/constants/labels/index.ts | 1 + src/lib/i18n/dictionaries/ar.ts | 20 +++ src/lib/i18n/dictionaries/de.ts | 22 +++ src/lib/i18n/dictionaries/en.ts | 22 +++ src/lib/i18n/dictionaries/fr.ts | 22 +++ src/lib/i18n/dictionaries/ru.ts | 21 +++ src/lib/i18n/dictionaries/uk.ts | 21 +++ 20 files changed, 905 insertions(+), 1 deletion(-) create mode 100644 prisma/migrations/20260901090000_complaints_about_the_organisation/migration.sql create mode 100644 src/app/(admin)/complaints/page.tsx create mode 100644 src/app/api/portal/complaints/route.ts create mode 100644 src/app/portal/complaints/ComplaintForm.tsx create mode 100644 src/app/portal/complaints/page.tsx create mode 100644 src/lib/actions/complaints.ts create mode 100644 src/lib/auth/__tests__/complaint-boundary.test.ts create mode 100644 src/lib/constants/labels/complaints.ts diff --git a/prisma/migrations/20260901090000_complaints_about_the_organisation/migration.sql b/prisma/migrations/20260901090000_complaints_about_the_organisation/migration.sql new file mode 100644 index 00000000..18f38f6c --- /dev/null +++ b/prisma/migrations/20260901090000_complaints_about_the_organisation/migration.sql @@ -0,0 +1,52 @@ +-- A channel that points at the organisation, not at the resident. +-- +-- The report form has offered exactly two destinations: a dripping tap to the +-- maintenance board, a roommate conflict to the incident ladder. An objection +-- to how AOZ itself acted fitted neither, and filing it as an Incident would +-- have been worse than dropping it: that ladder escalates TOWARD a resident and +-- ends in FORMAL_MEASURE, so complaining about staff would have opened a case +-- against the person complaining. +-- +-- The City's Eigentümerstrategie 2025-2028 fixes "Information und +-- Beschwerdestellen" as one of six minimum standards in AOZ's Leistungsauftrag. +-- AOZ's own central Beschwerdestelle logged 88 complaints in 2023, 145 in 2024 +-- and 242 in 2025 — 38% of the last figure about Unterbringung und +-- Zusammenleben, while the client count stayed flat. The product had no side of +-- that obligation at all. +CREATE TYPE "ComplaintSubject" AS ENUM ('STAFF', 'ACCOMMODATION', 'DECISION', 'OTHER'); +CREATE TYPE "ComplaintStatus" AS ENUM ('OPEN', 'IN_REVIEW', 'ANSWERED'); + +CREATE TABLE "Complaint" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + -- NULL = filed anonymously. Someone objecting to the organisation that + -- houses them pays a cost for being identifiable, so anonymity has to be on + -- offer. SET NULL rather than CASCADE on purpose: a complaint must outlive + -- the reporter's record, or deleting a person would erase what they said + -- about the service. + "residentId" TEXT, + + "subject" "ComplaintSubject" NOT NULL, + "body" TEXT NOT NULL, + "status" "ComplaintStatus" NOT NULL DEFAULT 'OPEN', + + "response" TEXT, + "respondedAt" TIMESTAMP(3), + "respondedByUserId" TEXT, + + CONSTRAINT "Complaint_pkey" PRIMARY KEY ("id") +); + +ALTER TABLE "Complaint" + ADD CONSTRAINT "Complaint_residentId_fkey" + FOREIGN KEY ("residentId") REFERENCES "Resident"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "Complaint" + ADD CONSTRAINT "Complaint_respondedByUserId_fkey" + FOREIGN KEY ("respondedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +CREATE INDEX "Complaint_status_idx" ON "Complaint"("status"); +CREATE INDEX "Complaint_residentId_idx" ON "Complaint"("residentId"); +CREATE INDEX "Complaint_createdAt_idx" ON "Complaint"("createdAt"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b84d8b96..a13fd8b0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -96,6 +96,7 @@ model Resident { /// endpoint already had, so nobody's visibility widens on migration. profileVisibility ProfileVisibility @default(ROOMMATES) + complaints Complaint[] messageThread MessageThread? messagesWritten Message[] @relation("MessageAuthor") @@ -232,6 +233,68 @@ model ResidentDocumentBlob { /// and making them pick a thread is asking them to do the filing for us. Staff /// get the whole history with that person in one place, which is also how they /// avoid asking something a colleague already answered. +/// A complaint about the ORGANISATION — never about a roommate. +/// +/// The report form already routes a dripping tap to the maintenance board and +/// a roommate conflict to the incident ladder. Neither fits an objection to how +/// AOZ itself acted, and filing one as an Incident would be actively harmful: +/// that ladder escalates TOWARD a resident and ends in FORMAL_MEASURE, so +/// complaining about staff would open a case against the person complaining. +/// +/// The City's Eigentümerstrategie fixes "Information und Beschwerdestellen" as +/// a minimum standard, and AOZ runs a central Beschwerdestelle that logged 242 +/// complaints in 2025 — 38% of them about Unterbringung und Zusammenleben. This +/// table is the product's side of that obligation. +/// +/// @see lib/auth/role-policy.ts — COMPLAINT_PERMISSIONS, and why oversight +/// over every care domain deliberately does NOT grant them. +model Complaint { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + /// NULL when filed anonymously. A person objecting to the organisation that + /// houses them is in a position where being identifiable is itself a cost, + /// so anonymity has to be available — the same reason the satisfaction + /// check-in is anonymous. The trade-off is real and stated in the form: + /// nobody can write back to an anonymous complaint, and it cannot appear in + /// "Deine Meldungen". + residentId String? + resident Resident? @relation(fields: [residentId], references: [id], onDelete: SetNull) + + subject ComplaintSubject + body String + + status ComplaintStatus @default(OPEN) + + /// What was written back. Shown to the resident in their own report list — + /// an answer stored and never rendered is the same as no answer. + response String? + respondedAt DateTime? + respondedByUserId String? + respondedBy User? @relation("ComplaintRespondedBy", fields: [respondedByUserId], references: [id], onDelete: SetNull) + + @@index([status]) + @@index([residentId]) + @@index([createdAt]) +} + +/// What the complaint is about. Deliberately coarse: a complaints form is not +/// a taxonomy exercise, and a resident should not have to classify their own +/// grievance precisely before being allowed to make it. +enum ComplaintSubject { + STAFF // How I was treated by someone working here + ACCOMMODATION // The accommodation itself, or its rules + DECISION // A decision that was made about me + OTHER +} + +enum ComplaintStatus { + OPEN + IN_REVIEW + ANSWERED +} + model MessageThread { id String @id @default(cuid()) residentId String @unique @@ -1001,6 +1064,7 @@ model User { applicationsSupported OpportunityApplication[] @relation("ApplicationSupportedBy") checkInsCollected SatisfactionCheckIn[] @relation("CheckInCollectedBy") documentsUploaded ResidentDocument[] @relation("DocumentUploadedBy") + complaintsAnswered Complaint[] @relation("ComplaintRespondedBy") // Login credentials (email + password) live on Account, never here. account Account? diff --git a/src/app/(admin)/complaints/page.tsx b/src/app/(admin)/complaints/page.tsx new file mode 100644 index 00000000..db4fe5e3 --- /dev/null +++ b/src/app/(admin)/complaints/page.tsx @@ -0,0 +1,111 @@ +import type { Metadata } from 'next' +import { prisma } from '@/lib/db' +import { requirePermission } from '@/lib/auth' +import { PageHeader } from '@/components/ui/Page' +import { SubmitButton } from '@/components/ui' +import { formatDate } from '@/lib/utils' +import { residentName } from '@/lib/utils/resident-name' +import { + COMPLAINT_LABELS as C, + COMPLAINT_STATUS_BADGES, + COMPLAINT_STATUS_LABELS, + COMPLAINT_SUBJECT_LABELS, +} from '@/lib/constants/labels' +import { respondToComplaint } from '@/lib/actions/complaints' + +export const metadata: Metadata = { title: C.staffTitle } +export const dynamic = 'force-dynamic' + +/** + * Complaints about the organisation. + * + * `complaints:read` — held by `isSystemAdmin` alone. Not by any care role, and + * deliberately NOT widened by `ALL_DOMAINS`: the person with oversight over + * every care domain is one of the people a complaint can be about. + * @see lib/auth/role-policy.ts — COMPLAINT_PERMISSIONS + */ +export default async function ComplaintsPage() { + await requirePermission('complaints:read') + + const complaints = await prisma.complaint.findMany({ + orderBy: [{ status: 'asc' }, { createdAt: 'desc' }], + select: { + id: true, + createdAt: true, + subject: true, + body: true, + status: true, + response: true, + respondedAt: true, + // An anonymous complaint has no resident, and the null IS the anonymity — + // there is nothing here to redact later because nothing was written. + resident: { select: { code: true, displayName: true } }, + respondedBy: { select: { name: true } }, + }, + }) + + return ( +
+ + + {complaints.length === 0 ? ( +

{C.staffEmpty}

+ ) : ( +
    + {complaints.map((complaint) => ( +
  • +
    +
    +

    {COMPLAINT_SUBJECT_LABELS[complaint.subject]}

    +

    + {C.filedOn} {formatDate(complaint.createdAt)} ·{' '} + {complaint.resident ? residentName(complaint.resident) : C.anonymousMarker} +

    +
    + + {COMPLAINT_STATUS_LABELS[complaint.status]} + +
    + +

    {complaint.body}

    + + {complaint.response ? ( +
    +

    {C.respondLabel}

    +

    + {complaint.response} +

    +

    + {C.respondedBy} {complaint.respondedBy?.name ?? '—'} + {complaint.respondedAt ? ` · ${formatDate(complaint.respondedAt)}` : ''} +

    +
    + ) : complaint.resident ? ( +
    + + +