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 (
+
+ ) : complaint.resident ? (
+
+ ) : (
+ // No form at all rather than a disabled one: there is nobody to
+ // send an answer to, and offering the box would imply otherwise.
+
{C.anonymousMarker}
+ )}
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/src/app/api/portal/complaints/route.ts b/src/app/api/portal/complaints/route.ts
new file mode 100644
index 00000000..33113744
--- /dev/null
+++ b/src/app/api/portal/complaints/route.ts
@@ -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
+ 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 })
+}
diff --git a/src/app/portal/complaints/ComplaintForm.tsx b/src/app/portal/complaints/ComplaintForm.tsx
new file mode 100644
index 00000000..448333c7
--- /dev/null
+++ b/src/app/portal/complaints/ComplaintForm.tsx
@@ -0,0 +1,139 @@
+'use client'
+
+import { useState } from 'react'
+import { COMPLAINT_SUBJECT_IDS } from '@/lib/constants/labels'
+
+type Subject = (typeof COMPLAINT_SUBJECT_IDS)[number]
+
+/**
+ * Filing a complaint about the organisation.
+ *
+ * Copy arrives as a prop, already translated by the server. A client component
+ * cannot call the request translator, and hardcoding German here is the leak
+ * the portal gates exist to prevent — `COMPLAINT_SUBJECT_IDS` is imported for
+ * the enum VALUES only, which are language-independent.
+ *
+ * The anonymity choice sits next to the submit button with its cost written
+ * beside it: anonymous means nobody can answer. Burying that would let someone
+ * choose anonymity and then wait for a reply that can never arrive.
+ */
+export interface ComplaintFormLabels {
+ subjectLabel: string
+ subjects: Record
+ bodyLabel: string
+ bodyPlaceholder: string
+ anonymousLabel: string
+ anonymousHint: string
+ submit: string
+ tooShort: string
+ sent: string
+ sentAnonymous: string
+}
+
+export function ComplaintForm({ labels }: { labels: ComplaintFormLabels }) {
+ const [subject, setSubject] = useState('STAFF')
+ const [body, setBody] = useState('')
+ const [anonymous, setAnonymous] = useState(false)
+ const [state, setState] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle')
+ const [message, setMessage] = useState('')
+
+ async function submit(event: React.FormEvent) {
+ event.preventDefault()
+ setState('sending')
+
+ const res = await fetch('/api/portal/complaints', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ subject, body, anonymous }),
+ }).catch(() => null)
+
+ const data = await res?.json().catch(() => null)
+
+ if (!res?.ok || !data?.success) {
+ setState('error')
+ setMessage(labels.tooShort)
+ return
+ }
+
+ // The confirmation differs by branch: an anonymous complaint must not
+ // promise an answer under "Deine Meldungen", because it will never appear
+ // there and nobody can write back to it.
+ setState('sent')
+ setMessage(anonymous ? labels.sentAnonymous : labels.sent)
+ }
+
+ if (state === 'sent') {
+ return (
+
+ {message}
+
+ )
+ }
+
+ return (
+
+ )
+}
diff --git a/src/app/portal/complaints/page.tsx b/src/app/portal/complaints/page.tsx
new file mode 100644
index 00000000..919df684
--- /dev/null
+++ b/src/app/portal/complaints/page.tsx
@@ -0,0 +1,67 @@
+import type { Metadata } from 'next'
+import { redirect } from 'next/navigation'
+import { getPortalResident } from '@/lib/portal-auth'
+import { getRequestTranslator } from '@/lib/i18n/request'
+import { ComplaintForm } from './ComplaintForm'
+
+export async function generateMetadata(): Promise {
+ const { t } = await getRequestTranslator()
+ return { title: t('nav.complaints') }
+}
+
+export const dynamic = 'force-dynamic'
+
+/**
+ * Where a resident objects to the organisation.
+ *
+ * A separate page from `/portal/report` on purpose. That form asks "is this a
+ * broken thing or a roommate problem", and folding a third option into it
+ * would put an objection to the Betreuung on the same footing as a dripping
+ * tap — and route it, one branch later, into the ladder that escalates against
+ * the person reporting.
+ *
+ * Every string here comes from the i18n dictionary, not from
+ * `lib/constants/labels`. The first version of this page 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.
+ */
+export default async function ComplaintsPage() {
+ const resident = await getPortalResident()
+ if (!resident) redirect('/login')
+
+ const { t } = await getRequestTranslator()
+
+ return (
+
+
+
{t('complaints.title')}
+
{t('complaints.intro')}
+
+
+ {/* Said before the form, not after it: whether the people it may be about
+ can read it is the fact that decides whether someone dares file. */}
+
{t('complaints.whoReads')}
+
+
+
+ )
+}
diff --git a/src/lib/actions/complaints.ts b/src/lib/actions/complaints.ts
new file mode 100644
index 00000000..f411b76f
--- /dev/null
+++ b/src/lib/actions/complaints.ts
@@ -0,0 +1,45 @@
+'use server'
+
+import { revalidatePath } from 'next/cache'
+import { prisma } from '@/lib/db'
+import { requirePermission } from '@/lib/auth'
+import { logger } from '@/lib/logger'
+import { ERROR_MESSAGES } from '@/lib/constants/error-messages'
+
+/**
+ * Answering a complaint.
+ *
+ * `complaints:respond`, which no care role holds and `ALL_DOMAINS` does not
+ * widen into — see role-policy.ts. A complaint about the Betreuung answered by
+ * the Betreuung is not an answer.
+ */
+export async function respondToComplaint(formData: FormData): Promise {
+ const user = await requirePermission('complaints:respond')
+
+ const complaintId = String(formData.get('complaintId') || '')
+ const response = String(formData.get('response') || '').trim()
+
+ if (!complaintId || response.length < 2) {
+ throw new Error(ERROR_MESSAGES.INVALID_REQUEST)
+ }
+
+ try {
+ await prisma.complaint.update({
+ where: { id: complaintId },
+ data: {
+ response,
+ respondedAt: new Date(),
+ respondedByUserId: user.id,
+ status: 'ANSWERED',
+ },
+ })
+ } catch (error) {
+ // The complaint body and the answer are both about a person's treatment.
+ // Neither goes to the logger.
+ logger.errorWithCause('Failed to record complaint response', error, { complaintId })
+ throw new Error(ERROR_MESSAGES.SAVE_ERROR)
+ }
+
+ revalidatePath('/complaints')
+ revalidatePath('/portal')
+}
diff --git a/src/lib/auth/__tests__/complaint-boundary.test.ts b/src/lib/auth/__tests__/complaint-boundary.test.ts
new file mode 100644
index 00000000..6f13360f
--- /dev/null
+++ b/src/lib/auth/__tests__/complaint-boundary.test.ts
@@ -0,0 +1,102 @@
+import {
+ COMPLAINT_PERMISSIONS,
+ STAFF_ROLES,
+ ROLE_PERMISSIONS,
+ hasPermission,
+ type StaffCapabilities,
+ type StaffRole,
+} from '../role-policy'
+
+/**
+ * A grievance channel whose reader may be its subject is not a grievance
+ * channel.
+ *
+ * Every other permission in this product widens with `ALL_DOMAINS`, because
+ * seeing every care domain is the entire point of that axis. These two must
+ * not — the person holding oversight over every domain is one of the people a
+ * complaint can be ABOUT. That exception is invisible in the permission table
+ * and would be undone by anyone "tidying up" the special case in
+ * `hasPermission`, so it is pinned here.
+ */
+
+const caps = (
+ role: StaffRole,
+ scope: StaffCapabilities['scope'] = 'OWN_DOMAIN',
+ isSystemAdmin = false,
+): StaffCapabilities => ({ role, scope, isSystemAdmin })
+
+describe('who may read a complaint about the organisation', () => {
+ it.each(COMPLAINT_PERMISSIONS.map((p) => [p]))(
+ '%s is held by no care role, at any scope',
+ (permission) => {
+ for (const role of STAFF_ROLES) {
+ expect({
+ role,
+ scope: 'OWN_DOMAIN',
+ granted: hasPermission(caps(role), permission),
+ }).toEqual({ role, scope: 'OWN_DOMAIN', granted: false })
+
+ // The one that matters. Franziska is BETREUUNG + ALL_DOMAINS, and a
+ // complaint may be about Franziska.
+ expect({
+ role,
+ scope: 'ALL_DOMAINS',
+ granted: hasPermission(caps(role, 'ALL_DOMAINS'), permission),
+ }).toEqual({ role, scope: 'ALL_DOMAINS', granted: false })
+ }
+ },
+ )
+
+ it.each(COMPLAINT_PERMISSIONS.map((p) => [p]))('%s is granted by isSystemAdmin', (permission) => {
+ expect(hasPermission(caps('BETREUUNG', 'OWN_DOMAIN', true), permission)).toBe(true)
+ })
+
+ it('appears in no role’s permission list, so ALL_DOMAINS cannot pick it up', () => {
+ // `hasPermission`'s ALL_DOMAINS branch grants anything ANY role holds. If a
+ // complaint verb were ever added to a role, oversight would inherit it and
+ // the check above would start passing for the wrong reason.
+ for (const role of STAFF_ROLES) {
+ const held = ROLE_PERMISSIONS[role] as readonly string[]
+ for (const permission of COMPLAINT_PERMISSIONS) {
+ expect({ role, permission, listed: held.includes(permission) }).toEqual({
+ role,
+ permission,
+ listed: false,
+ })
+ }
+ }
+ })
+})
+
+describe('a complaint never becomes a case against the person who filed it', () => {
+ /**
+ * The reason this table exists at all. `/api/portal/report` routes to the
+ * maintenance board or the incident ladder, and that ladder escalates TOWARD
+ * a resident, ending in FORMAL_MEASURE. Filing an objection to the Betreuung
+ * as an Incident would open a case against the complainant.
+ */
+ const fs = require('fs') as typeof import('fs')
+ const path = require('path') as typeof import('path')
+ const ROUTE = path.resolve(__dirname, '../../../app/api/portal/complaints/route.ts')
+
+ it('the complaint route writes only to the complaint table', () => {
+ const source = fs.readFileSync(ROUTE, 'utf8')
+ expect(source).toMatch(/prisma\.complaint\.create/)
+ expect(source).not.toMatch(/prisma\.incident\.create/)
+ expect(source).not.toMatch(/prisma\.maintenanceRequest\.create/)
+ })
+
+ it('an anonymous complaint stores no resident, and no audit row names one', () => {
+ const source = fs.readFileSync(ROUTE, 'utf8')
+ // Comments stripped first. The route EXPLAINS in prose why it does not
+ // call logAudit, and the first version of this assertion matched that
+ // explanation and failed — a gate that reads documentation reports the
+ // reasoning for a rule as a breach of it.
+ const code = source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
+
+ // The anonymity IS the null. An audit entry naming the reporter would
+ // quietly undo what the form promises.
+ expect(code).toMatch(/anonymous\s*\?\s*null\s*:\s*resident\.id/)
+ expect(code).not.toMatch(/logAudit/)
+ })
+})
diff --git a/src/lib/auth/role-policy.ts b/src/lib/auth/role-policy.ts
index bfc51752..e70fef81 100644
--- a/src/lib/auth/role-policy.ts
+++ b/src/lib/auth/role-policy.ts
@@ -83,6 +83,25 @@ export const SYSTEM_ADMIN_PERMISSIONS = [
'import:write',
] as const
+/**
+ * Reading and answering complaints about the organisation itself.
+ *
+ * Held by NO care role, and — this is the part that matters — not by
+ * `ALL_DOMAINS` either. Every other permission widens with oversight, because
+ * seeing every domain is the point of that axis. This one must not, because
+ * the person with oversight over every 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.
+ *
+ * Named separately from SYSTEM_ADMIN_PERMISSIONS even though `isSystemAdmin`
+ * is what grants both today. They are different jobs: configuring the product
+ * and hearing a complaint against staff. AOZ runs a central Beschwerdestelle
+ * that is deliberately independent of the site team, and when a real person
+ * fills that seat here they should get these two verbs without also being
+ * handed the settings page — which is the whole lesson of retiring ADMIN.
+ */
+export const COMPLAINT_PERMISSIONS = ['complaints:read', 'complaints:respond'] as const
+
const OPERATIONAL = [
'dashboard:read',
'residents:read',
@@ -192,7 +211,9 @@ export const ROLE_PERMISSIONS = {
} as const
export type StaffPermission =
- (typeof ROLE_PERMISSIONS)[StaffRole][number] | (typeof SYSTEM_ADMIN_PERMISSIONS)[number]
+ | (typeof ROLE_PERMISSIONS)[StaffRole][number]
+ | (typeof SYSTEM_ADMIN_PERMISSIONS)[number]
+ | (typeof COMPLAINT_PERMISSIONS)[number]
/**
* The narrowest possible subject, derived rather than named.
@@ -244,6 +265,14 @@ export function hasPermission(subject: StaffCapabilities, permission: string): b
return subject.isSystemAdmin
}
+ // Checked HERE, above the role and scope logic, for the same reason system
+ // permissions are: falling through would let the `ALL_DOMAINS` branch below
+ // grant it, and oversight over every domain must not include reading
+ // complaints that may name the person holding it.
+ if ((COMPLAINT_PERMISSIONS as readonly string[]).includes(permission)) {
+ return subject.isSystemAdmin
+ }
+
if (grantsPermission(subject.role, permission)) return true
// Seeing every domain means working every seat — a Betreuerin covering the
diff --git a/src/lib/auth/route-boundaries.ts b/src/lib/auth/route-boundaries.ts
index d0459f76..51666b00 100644
--- a/src/lib/auth/route-boundaries.ts
+++ b/src/lib/auth/route-boundaries.ts
@@ -26,6 +26,7 @@ export const STAFF_ROUTES = [
'/algorithm',
'/analytics',
'/chores',
+ '/complaints',
'/events',
'/housing',
'/incidents',
@@ -54,6 +55,8 @@ export const RESIDENT_ROUTES = [
'/portal/preferences',
'/portal/roommates',
'/portal/report',
+ '/portal/complaints',
+ '/api/portal/complaints',
'/portal/chores',
'/portal/transfer',
'/portal/expenses',
diff --git a/src/lib/config/navigation.ts b/src/lib/config/navigation.ts
index 9ccd26cb..c8853f22 100644
--- a/src/lib/config/navigation.ts
+++ b/src/lib/config/navigation.ts
@@ -89,6 +89,9 @@ export interface NavItem {
*/
export const SYSTEM_LINKS: NavItem[] = [
{ href: '/settings', icon: 'settings', label: 'Einstellungen', permission: 'users:manage' },
+ // Complaints about the organisation. A system link, not a mission area: it is
+ // deliberately outside the care groups, because the care team cannot see it.
+ { href: '/complaints', icon: 'alert', label: 'Beschwerden', permission: 'complaints:read' },
// A utility OVER the work, not one of the mission areas — the same kind of
// thing as the algorithm docs and settings it now sits beside. It was also
// costing 128px of a bar that did not have them: measured on a 1440px
@@ -418,6 +421,7 @@ export interface PortalNavItem {
| 'activities'
| 'report'
| 'reports'
+ | 'complaints'
| 'preferences'
| 'profile'
| 'help'
@@ -524,6 +528,9 @@ export const PORTAL_NAV_ITEMS: PortalNavItem[] = [
group: 'concerns',
},
{ href: '/portal/reports', labelKey: 'reports', icon: 'clipboard', group: 'concerns' },
+ // Objecting to the organisation belongs beside "where did my report go" —
+ // it is the same question a resident arrives with, pointed the other way.
+ { href: '/portal/complaints', labelKey: 'complaints', icon: 'scroll', group: 'concerns' },
{
href: '/portal/messages',
labelKey: 'messages',
diff --git a/src/lib/config/permission-descriptions.ts b/src/lib/config/permission-descriptions.ts
index 1cda74b7..4265d35c 100644
--- a/src/lib/config/permission-descriptions.ts
+++ b/src/lib/config/permission-descriptions.ts
@@ -44,6 +44,8 @@ export const PERMISSION_DESCRIPTIONS: Record = {
'users:manage': 'Benutzer*innen und Einstellungen verwalten',
'system:configure': 'Systemeinstellungen ändern',
'import:write': 'Daten importieren',
+ 'complaints:read': 'Beschwerden über die Organisation einsehen',
+ 'complaints:respond': 'Beschwerden über die Organisation beantworten',
}
/**
diff --git a/src/lib/constants/labels/complaints.ts b/src/lib/constants/labels/complaints.ts
new file mode 100644
index 00000000..24d3a38b
--- /dev/null
+++ b/src/lib/constants/labels/complaints.ts
@@ -0,0 +1,71 @@
+import type { ComplaintStatus, ComplaintSubject } from '@prisma/client'
+
+/**
+ * Complaints about the organisation — resident-facing and staff-facing German.
+ *
+ * The tone is doing work here. Someone objecting to the organisation that
+ * houses them is taking a risk, and copy that sounds like a form will read as
+ * a discouragement. So the words say plainly that complaining is allowed, that
+ * it changes nothing about their housing, and what happens next.
+ */
+
+/** SSOT for the enum's values, so the zod schema and the form share one list. */
+export const COMPLAINT_SUBJECT_IDS = ['STAFF', 'ACCOMMODATION', 'DECISION', 'OTHER'] as const
+
+export const COMPLAINT_SUBJECT_LABELS: Record = {
+ STAFF: 'Wie ich behandelt wurde',
+ ACCOMMODATION: 'Die Unterkunft oder ihre Regeln',
+ DECISION: 'Eine Entscheidung über mich',
+ OTHER: 'Etwas anderes',
+}
+
+export const COMPLAINT_STATUS_LABELS: Record = {
+ OPEN: 'Eingegangen',
+ IN_REVIEW: 'In Prüfung',
+ ANSWERED: 'Beantwortet',
+}
+
+export const COMPLAINT_STATUS_BADGES: Record = {
+ OPEN: 'badge-pending',
+ IN_REVIEW: 'badge-active',
+ ANSWERED: 'badge-ended',
+}
+
+export const COMPLAINT_LABELS = {
+ // --- Resident side ---
+ navLabel: 'Beschwerde',
+ title: 'Beschwerde über die Betreuung',
+ intro:
+ 'Wenn du mit der Betreuung, der Unterkunft oder einer Entscheidung nicht einverstanden bist, kannst du das hier sagen. Eine Beschwerde hat keine Folgen für deinen Platz.',
+ /**
+ * Says who does NOT read it. That is the fact that makes the channel usable:
+ * a complaint read by the person it is about is not a complaint.
+ */
+ whoReads:
+ 'Deine Beschwerde geht an die zuständige Stelle — nicht an die Betreuungspersonen in deiner Unterkunft.',
+ subjectLabel: 'Worum geht es?',
+ bodyLabel: 'Was ist passiert?',
+ bodyPlaceholder: 'Beschreibe, was vorgefallen ist. Wann, wo, und wer beteiligt war.',
+ anonymousLabel: 'Anonym einreichen',
+ /** The trade-off, stated where the choice is made rather than buried. */
+ anonymousHint:
+ 'Anonym heisst: dein Name steht nicht dabei. Wir können dir dann aber nicht antworten, und die Beschwerde erscheint nicht unter "Deine Meldungen".',
+ submit: 'Beschwerde einreichen',
+ tooShort: 'Bitte beschreibe kurz, worum es geht (mindestens 10 Zeichen).',
+ sent: 'Deine Beschwerde ist eingegangen. Du siehst die Antwort unter "Deine Meldungen".',
+ sentAnonymous: 'Deine anonyme Beschwerde ist eingegangen.',
+ /** How it reads in the resident's merged report list. */
+ reportTitle: 'Beschwerde',
+
+ // --- Staff side ---
+ staffTitle: 'Beschwerden',
+ staffSubtitle:
+ 'Beschwerden über die Organisation. Nicht sichtbar für die Betreuung — auch nicht mit Einsicht in alle Bereiche.',
+ staffEmpty: 'Keine Beschwerden eingegangen.',
+ anonymousMarker: 'Anonym',
+ respondLabel: 'Antwort',
+ respondPlaceholder: 'Was wurde geprüft, und was folgt daraus?',
+ respondSubmit: 'Antwort senden',
+ respondedBy: 'Beantwortet von',
+ filedOn: 'Eingegangen',
+} as const
diff --git a/src/lib/constants/labels/index.ts b/src/lib/constants/labels/index.ts
index 58a157f5..bf8df9ce 100644
--- a/src/lib/constants/labels/index.ts
+++ b/src/lib/constants/labels/index.ts
@@ -28,3 +28,4 @@ export * from './marketplace'
export * from './events'
export * from './opportunities'
export * from './vulnerability'
+export * from './complaints'
diff --git a/src/lib/i18n/dictionaries/ar.ts b/src/lib/i18n/dictionaries/ar.ts
index 5f589f3d..e4979437 100644
--- a/src/lib/i18n/dictionaries/ar.ts
+++ b/src/lib/i18n/dictionaries/ar.ts
@@ -48,6 +48,26 @@ export const ar: Dictionary = {
'reports.showAll': 'عرض كل البلاغات',
'reports.empty': 'لم تُبلّغ عن أي شيء بعد.',
'reports.new': 'بلاغ جديد',
+ // شكوى بشأن المنظمة — وليست بشأن شريك السكن.
+ 'nav.complaints': 'شكوى',
+ 'complaints.title': 'شكوى بشأن الرعاية',
+ 'complaints.intro':
+ 'إذا لم تكن موافقًا على الرعاية أو السكن أو قرار ما، يمكنك قول ذلك هنا. الشكوى ليس لها أي تأثير على مكان إقامتك.',
+ 'complaints.whoReads': 'تصل شكواك إلى الجهة المختصة — وليس إلى العاملين في مكان سكنك.',
+ 'complaints.subjectLabel': 'ما هو الموضوع؟',
+ 'complaints.subject.STAFF': 'الطريقة التي عوملت بها',
+ 'complaints.subject.ACCOMMODATION': 'السكن أو قواعده',
+ 'complaints.subject.DECISION': 'قرار يخصني',
+ 'complaints.subject.OTHER': 'شيء آخر',
+ 'complaints.bodyLabel': 'ماذا حدث؟',
+ 'complaints.bodyPlaceholder': 'صف ما حدث: متى وأين ومن كان معنيًا.',
+ 'complaints.anonymousLabel': 'إرسال بدون ذكر الاسم',
+ 'complaints.anonymousHint':
+ 'بدون ذكر الاسم يعني أن اسمك لن يُذكر. عندها لا يمكننا الرد عليك، ولن تظهر الشكوى ضمن «بلاغاتك».',
+ 'complaints.submit': 'إرسال الشكوى',
+ 'complaints.tooShort': 'يرجى وصف الموضوع باختصار (10 أحرف على الأقل).',
+ 'complaints.sent': 'تم استلام شكواك. سترى الرد ضمن «بلاغاتك».',
+ 'complaints.sentAnonymous': 'تم استلام شكواك المجهولة.',
'reports.open': 'مفتوح',
'reports.done': 'تم الحل',
'reports.pending': 'الفريق يراجع هذا البلاغ حاليًا.',
diff --git a/src/lib/i18n/dictionaries/de.ts b/src/lib/i18n/dictionaries/de.ts
index 8531956b..ef2762ca 100644
--- a/src/lib/i18n/dictionaries/de.ts
+++ b/src/lib/i18n/dictionaries/de.ts
@@ -56,6 +56,28 @@ export const de = {
'reports.showAll': 'Alle Meldungen anzeigen',
'reports.empty': 'Du hast noch nichts gemeldet.',
'reports.new': 'Neu melden',
+ // Beschwerde über die Organisation — nicht über Mitbewohnende.
+ 'nav.complaints': 'Beschwerde',
+ 'complaints.title': 'Beschwerde über die Betreuung',
+ 'complaints.intro':
+ 'Wenn du mit der Betreuung, der Unterkunft oder einer Entscheidung nicht einverstanden bist, kannst du das hier sagen. Eine Beschwerde hat keine Folgen für deinen Platz.',
+ 'complaints.whoReads':
+ 'Deine Beschwerde geht an die zuständige Stelle — nicht an die Betreuungspersonen in deiner Unterkunft.',
+ 'complaints.subjectLabel': 'Worum geht es?',
+ 'complaints.subject.STAFF': 'Wie ich behandelt wurde',
+ 'complaints.subject.ACCOMMODATION': 'Die Unterkunft oder ihre Regeln',
+ 'complaints.subject.DECISION': 'Eine Entscheidung über mich',
+ 'complaints.subject.OTHER': 'Etwas anderes',
+ 'complaints.bodyLabel': 'Was ist passiert?',
+ 'complaints.bodyPlaceholder': 'Beschreibe, was vorgefallen ist: wann, wo und wer beteiligt war.',
+ 'complaints.anonymousLabel': 'Anonym einreichen',
+ 'complaints.anonymousHint':
+ 'Anonym heisst: dein Name steht nicht dabei. Wir können dir dann aber nicht antworten, und die Beschwerde erscheint nicht unter "Deine Meldungen".',
+ 'complaints.submit': 'Beschwerde einreichen',
+ 'complaints.tooShort': 'Bitte beschreibe kurz, worum es geht (mindestens 10 Zeichen).',
+ 'complaints.sent':
+ 'Deine Beschwerde ist eingegangen. Du siehst die Antwort unter "Deine Meldungen".',
+ 'complaints.sentAnonymous': 'Deine anonyme Beschwerde ist eingegangen.',
'reports.open': 'Offen',
'reports.done': 'Erledigt',
'reports.pending': 'Das Team prüft diese Meldung aktuell.',
diff --git a/src/lib/i18n/dictionaries/en.ts b/src/lib/i18n/dictionaries/en.ts
index aee7ae33..7a50ae16 100644
--- a/src/lib/i18n/dictionaries/en.ts
+++ b/src/lib/i18n/dictionaries/en.ts
@@ -38,6 +38,28 @@ export const en: Dictionary = {
'reports.showAll': 'Show all reports',
'reports.empty': 'You have not reported anything yet.',
'reports.new': 'New report',
+ // Complaint about the organisation — not about a roommate.
+ 'nav.complaints': 'Complaint',
+ 'complaints.title': 'Complaint about the support you receive',
+ 'complaints.intro':
+ 'If you disagree with the support, the accommodation or a decision, you can say so here. Making a complaint has no consequences for your place.',
+ 'complaints.whoReads':
+ 'Your complaint goes to the responsible office — not to the support staff in your accommodation.',
+ 'complaints.subjectLabel': 'What is it about?',
+ 'complaints.subject.STAFF': 'How I was treated',
+ 'complaints.subject.ACCOMMODATION': 'The accommodation or its rules',
+ 'complaints.subject.DECISION': 'A decision about me',
+ 'complaints.subject.OTHER': 'Something else',
+ 'complaints.bodyLabel': 'What happened?',
+ 'complaints.bodyPlaceholder': 'Describe what happened: when, where and who was involved.',
+ 'complaints.anonymousLabel': 'Submit anonymously',
+ 'complaints.anonymousHint':
+ 'Anonymous means your name is not attached. We then cannot reply to you, and the complaint will not appear under "Your reports".',
+ 'complaints.submit': 'Submit complaint',
+ 'complaints.tooShort': 'Please describe briefly what this is about (at least 10 characters).',
+ 'complaints.sent':
+ 'Your complaint has been received. You will see the reply under "Your reports".',
+ 'complaints.sentAnonymous': 'Your anonymous complaint has been received.',
'reports.open': 'Open',
'reports.done': 'Resolved',
'reports.pending': 'The team is looking at this report.',
diff --git a/src/lib/i18n/dictionaries/fr.ts b/src/lib/i18n/dictionaries/fr.ts
index 4aef2d6b..976c9afb 100644
--- a/src/lib/i18n/dictionaries/fr.ts
+++ b/src/lib/i18n/dictionaries/fr.ts
@@ -47,6 +47,28 @@ export const fr: Dictionary = {
'reports.showAll': 'Afficher tous les signalements',
'reports.empty': 'Tu n’as encore rien signalé.',
'reports.new': 'Nouveau signalement',
+ // Réclamation concernant l'organisation — pas un colocataire.
+ 'nav.complaints': 'Réclamation',
+ 'complaints.title': "Réclamation concernant l'encadrement",
+ 'complaints.intro':
+ "Si tu n'es pas d'accord avec l'encadrement, le logement ou une décision, tu peux le dire ici. Une réclamation n'a aucune conséquence sur ta place.",
+ 'complaints.whoReads':
+ "Ta réclamation est transmise au service compétent — pas aux personnes qui t'encadrent dans ton logement.",
+ 'complaints.subjectLabel': "De quoi s'agit-il ?",
+ 'complaints.subject.STAFF': "La façon dont j'ai été traité·e",
+ 'complaints.subject.ACCOMMODATION': 'Le logement ou son règlement',
+ 'complaints.subject.DECISION': 'Une décision me concernant',
+ 'complaints.subject.OTHER': 'Autre chose',
+ 'complaints.bodyLabel': "Que s'est-il passé ?",
+ 'complaints.bodyPlaceholder': "Décris ce qui s'est passé : quand, où et qui était impliqué.",
+ 'complaints.anonymousLabel': 'Envoyer anonymement',
+ 'complaints.anonymousHint':
+ "Anonyme signifie que ton nom n'apparaît pas. Nous ne pourrons alors pas te répondre, et la réclamation n'apparaîtra pas sous « Tes signalements ».",
+ 'complaints.submit': 'Envoyer la réclamation',
+ 'complaints.tooShort': "Décris brièvement de quoi il s'agit (au moins 10 caractères).",
+ 'complaints.sent':
+ 'Ta réclamation a bien été reçue. Tu verras la réponse sous « Tes signalements ».',
+ 'complaints.sentAnonymous': 'Ta réclamation anonyme a bien été reçue.',
'reports.open': 'Ouvert',
'reports.done': 'Résolu',
'reports.pending': 'L’équipe examine ce signalement.',
diff --git a/src/lib/i18n/dictionaries/ru.ts b/src/lib/i18n/dictionaries/ru.ts
index 9f88f202..3ec00d0b 100644
--- a/src/lib/i18n/dictionaries/ru.ts
+++ b/src/lib/i18n/dictionaries/ru.ts
@@ -58,6 +58,27 @@ export const ru: Dictionary = {
'reports.showAll': 'Показать все обращения',
'reports.empty': 'Ты ещё ни о чём не сообщал.',
'reports.new': 'Новое обращение',
+ // Жалоба на организацию — не на соседей.
+ 'nav.complaints': 'Жалоба',
+ 'complaints.title': 'Жалоба на сопровождение',
+ 'complaints.intro':
+ 'Если ты не согласен с сопровождением, жильём или решением, ты можешь сказать об этом здесь. Жалоба не повлияет на твоё место проживания.',
+ 'complaints.whoReads':
+ 'Твоя жалоба поступит в ответственную инстанцию — не к сотрудникам, которые работают в твоём жилье.',
+ 'complaints.subjectLabel': 'О чём идёт речь?',
+ 'complaints.subject.STAFF': 'Как со мной обошлись',
+ 'complaints.subject.ACCOMMODATION': 'Жильё или его правила',
+ 'complaints.subject.DECISION': 'Решение обо мне',
+ 'complaints.subject.OTHER': 'Другое',
+ 'complaints.bodyLabel': 'Что произошло?',
+ 'complaints.bodyPlaceholder': 'Опиши, что случилось: когда, где и кто был причастен.',
+ 'complaints.anonymousLabel': 'Отправить анонимно',
+ 'complaints.anonymousHint':
+ 'Анонимно означает, что твоё имя не указывается. Тогда мы не сможем тебе ответить, и жалоба не появится в разделе «Твои сообщения».',
+ 'complaints.submit': 'Отправить жалобу',
+ 'complaints.tooShort': 'Пожалуйста, кратко опиши, о чём речь (минимум 10 символов).',
+ 'complaints.sent': 'Твоя жалоба получена. Ответ появится в разделе «Твои сообщения».',
+ 'complaints.sentAnonymous': 'Твоя анонимная жалоба получена.',
'reports.open': 'Открыто',
'reports.done': 'Решено',
'reports.pending': 'Команда рассматривает это обращение.',
diff --git a/src/lib/i18n/dictionaries/uk.ts b/src/lib/i18n/dictionaries/uk.ts
index 6c7028f0..beec0b3e 100644
--- a/src/lib/i18n/dictionaries/uk.ts
+++ b/src/lib/i18n/dictionaries/uk.ts
@@ -46,6 +46,27 @@ export const uk: Dictionary = {
'reports.showAll': 'Показати всі звернення',
'reports.empty': 'Ви ще нічого не повідомляли.',
'reports.new': 'Нове звернення',
+ // Скарга на організацію — не на сусідів.
+ 'nav.complaints': 'Скарга',
+ 'complaints.title': 'Скарга на супровід',
+ 'complaints.intro':
+ 'Якщо ти не згоден із супроводом, житлом або рішенням, ти можеш сказати про це тут. Скарга не вплине на твоє місце проживання.',
+ 'complaints.whoReads':
+ 'Твоя скарга надійде до відповідальної інстанції — не до працівників, які працюють у твоєму житлі.',
+ 'complaints.subjectLabel': 'Про що йдеться?',
+ 'complaints.subject.STAFF': 'Як зі мною повелися',
+ 'complaints.subject.ACCOMMODATION': 'Житло або його правила',
+ 'complaints.subject.DECISION': 'Рішення щодо мене',
+ 'complaints.subject.OTHER': 'Інше',
+ 'complaints.bodyLabel': 'Що сталося?',
+ 'complaints.bodyPlaceholder': 'Опиши, що сталося: коли, де і хто був причетний.',
+ 'complaints.anonymousLabel': 'Надіслати анонімно',
+ 'complaints.anonymousHint':
+ 'Анонімно означає, що твоє ім’я не вказується. Тоді ми не зможемо тобі відповісти, і скарга не з’явиться в розділі «Твої звернення».',
+ 'complaints.submit': 'Надіслати скаргу',
+ 'complaints.tooShort': 'Будь ласка, коротко опиши, про що йдеться (щонайменше 10 символів).',
+ 'complaints.sent': 'Твою скаргу отримано. Відповідь з’явиться в розділі «Твої звернення».',
+ 'complaints.sentAnonymous': 'Твою анонімну скаргу отримано.',
'reports.open': 'Відкрито',
'reports.done': 'Вирішено',
'reports.pending': 'Команда розглядає це звернення.',