Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -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");
64 changes: 64 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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?

Expand Down
111 changes: 111 additions & 0 deletions src/app/(admin)/complaints/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="max-w-3xl space-y-6">
<PageHeader title={C.staffTitle} description={C.staffSubtitle} />

{complaints.length === 0 ? (
<p className="text-ui-muted">{C.staffEmpty}</p>
) : (
<ul className="space-y-4">
{complaints.map((complaint) => (
<li key={complaint.id} className="card">
<div className="flex items-start justify-between gap-3">
<div>
<p className="eyebrow">{COMPLAINT_SUBJECT_LABELS[complaint.subject]}</p>
<p className="text-xs text-ui-muted mt-1">
{C.filedOn} {formatDate(complaint.createdAt)} ·{' '}
{complaint.resident ? residentName(complaint.resident) : C.anonymousMarker}
</p>
</div>
<span className={COMPLAINT_STATUS_BADGES[complaint.status]}>
{COMPLAINT_STATUS_LABELS[complaint.status]}
</span>
</div>

<p className="text-sm text-ui-text mt-3 whitespace-pre-wrap">{complaint.body}</p>

{complaint.response ? (
<div className="mt-4 border-t border-ui-border pt-3">
<p className="eyebrow">{C.respondLabel}</p>
<p className="text-sm text-ui-text mt-1 whitespace-pre-wrap">
{complaint.response}
</p>
<p className="text-xs text-ui-muted mt-1">
{C.respondedBy} {complaint.respondedBy?.name ?? '—'}
{complaint.respondedAt ? ` · ${formatDate(complaint.respondedAt)}` : ''}
</p>
</div>
) : complaint.resident ? (
<form action={respondToComplaint} className="mt-4 space-y-2">
<input type="hidden" name="complaintId" value={complaint.id} />
<label className="label" htmlFor={`response-${complaint.id}`}>
{C.respondLabel}
</label>
<textarea
id={`response-${complaint.id}`}
name="response"
className="input min-h-[90px]"
placeholder={C.respondPlaceholder}
required
/>
<SubmitButton className="btn-primary min-h-[44px]">
{C.respondSubmit}
</SubmitButton>
</form>
) : (
// No form at all rather than a disabled one: there is nobody to
// send an answer to, and offering the box would imply otherwise.
<p className="text-xs text-ui-muted mt-4">{C.anonymousMarker}</p>
)}
</li>
))}
</ul>
)}
</div>
)
}
83 changes: 83 additions & 0 deletions src/app/api/portal/complaints/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { prisma } from '@/lib/db'
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { logger } from '@/lib/logger'
import { ERROR_MESSAGES } from '@/lib/constants/error-messages'
import { getResidentCookie } from '@/lib/portal-auth'
import { COMPLAINT_SUBJECT_IDS } from '@/lib/constants/labels'

/**
* A resident objects to the ORGANISATION.
*
* Deliberately NOT part of `/api/portal/report`, which routes to the
* maintenance board or the incident ladder. Sharing that route would have
* meant one more branch in a function whose whole job is choosing between two
* destinations that are both wrong here — and the failure mode of getting the
* branch wrong is a complaint about staff becoming a case against the
* resident. Different obligation, different table, different route.
*
* Note what is NOT audited: `logAudit` records who did what to whom, and the
* point of an anonymous complaint is that no such record exists. Writing an
* audit row naming the resident would quietly undo the anonymity the form
* promises.
*/

const complaintSchema = z.object({
subject: z.enum(COMPLAINT_SUBJECT_IDS),
body: z.string().trim().min(10).max(4000),
anonymous: z.boolean().default(false),
})

export async function POST(request: NextRequest) {
const residentCode = await getResidentCookie()
if (!residentCode) {
return NextResponse.json(
{ success: false, error: ERROR_MESSAGES.NOT_AUTHENTICATED },
{ status: 401 },
)
}

// Signing in is still required — an open endpoint would take complaints from
// anyone on the internet, and a channel full of noise protects nobody. What
// "anonymous" changes is whether the RECORD carries the identity, not
// whether the sender had one.
const resident = await prisma.resident.findUnique({
where: { code: residentCode },
select: { id: true },
})
if (!resident) {
return NextResponse.json(
{ success: false, error: ERROR_MESSAGES.NOT_AUTHENTICATED },
{ status: 401 },
)
}

let parsed: z.infer<typeof complaintSchema>
try {
parsed = complaintSchema.parse(await request.json())
} catch {
return NextResponse.json(
{ success: false, error: ERROR_MESSAGES.INVALID_REQUEST },
{ status: 400 },
)
}

try {
await prisma.complaint.create({
data: {
residentId: parsed.anonymous ? null : resident.id,
subject: parsed.subject,
body: parsed.body,
},
})
} catch (error) {
// The body is a person's complaint. It never goes to the logger.
logger.errorWithCause('Failed to record complaint', error)
return NextResponse.json({ success: false, error: ERROR_MESSAGES.SAVE_ERROR }, { status: 500 })
}

// No message: the confirmation is the CLIENT's to render, already
// translated. Returning German prose from an API the portal calls is the
// leak the portal gates exist to catch — the resident may not read it.
return NextResponse.json({ success: true, anonymous: parsed.anonymous })
}
Loading
Loading