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}

+ ) : ( +