wg-verwaltung/backend/src/routes/ratings.ts
bernd 791b9bb01e Add peer rating system for tenants
Implements the previously-deferred "Bewertungssystem für Zwischenmieter"
requirement, which existed only as a vague idea with no model, route, or
UI. Design (confirmed with user): any authenticated user can rate any
tenant (1-5 + optional comment), freestanding (not tied to a contract,
creatable any time), visible only to LANDLORD/ADMIN — tenants can submit
ratings but not view them, to keep WG-internal friction out of the open.

- prisma: TenantRating model + User relations. Also pins the Prisma
  Client `output` path explicitly: since schema.prisma lives at the repo
  root (no package.json there) while node_modules only exists under
  backend/, `prisma generate`'s root-inference walked up past the repo
  and wrote into an unrelated ancestor directory when invoked from a
  fresh checkout. The explicit relative output keeps repo-root schema +
  backend-only deps working the same locally and in Docker.
- backend: GET/POST /v1/ratings (role-gated read), GET /v1/ratings/tenants
  (name+room only, any authenticated user, for the picker).
- dashboard: rating form for everyone, landlord-only ratings/summary view.
2026-08-13 07:39:32 +00:00

99 lines
3.6 KiB
TypeScript

import { Router, Response } from 'express';
import { PrismaClient } from '@prisma/client';
import { AuthedRequest, requireAuth, requireRole } from '../middleware/auth';
const prisma = new PrismaClient();
/**
* Peer-Bewertungssystem für (Zwischen-)Mieter.
*
* Jeder authentifizierte Nutzer darf einen Mieter bewerten (Sauberkeit,
* Zuverlässigkeit, WG-Verträglichkeit). Bewertungen sind bewusst freistehend
* (kein Bezug zu einem Contract) und jederzeit erfassbar. Einsehbar sind sie
* ausschließlich für Vermieter/Admin — Mitbewohner sehen weder eigene noch
* fremde Bewertungen, um WG-interne Konflikte durch offene Kritik zu
* vermeiden.
*/
export const ratingsRouter = Router();
// Für jeden authentifizierten Nutzer sichtbar (Name + Zimmer, keine sensiblen
// Daten) — Grundlage für die Auswahl im Bewertungsformular.
ratingsRouter.get('/ratings/tenants', requireAuth, async (_req: AuthedRequest, res: Response) => {
const tenants = await prisma.user.findMany({
where: { role: 'TENANT' },
select: { id: true, fullName: true, room: { select: { roomNumber: true } } },
orderBy: { fullName: 'asc' },
});
res.status(200).json({ tenants });
});
ratingsRouter.post('/ratings', requireAuth, async (req: AuthedRequest, res: Response) => {
const { ratedUserId, score, comment } = req.body || {};
if (!ratedUserId || typeof ratedUserId !== 'string') {
return res.status(400).json({ error: 'ratedUserId ist erforderlich' });
}
if (ratedUserId === req.user!.id) {
return res.status(400).json({ error: 'Du kannst dich nicht selbst bewerten' });
}
const numericScore = Number(score);
if (!Number.isInteger(numericScore) || numericScore < 1 || numericScore > 5) {
return res.status(400).json({ error: 'score muss eine Ganzzahl zwischen 1 und 5 sein' });
}
const ratedUser = await prisma.user.findUnique({ where: { id: ratedUserId } });
if (!ratedUser || ratedUser.role !== 'TENANT') {
return res.status(400).json({ error: 'ratedUserId muss ein Mieter sein' });
}
const rating = await prisma.tenantRating.create({
data: {
raterId: req.user!.id,
ratedUserId,
score: numericScore,
comment: typeof comment === 'string' && comment.trim() ? comment.trim() : null,
},
});
res.status(201).json({ rating });
});
// Nur Vermieter/Admin dürfen Bewertungen einsehen — optional gefiltert nach
// bewertetem Mieter (?userId=...).
ratingsRouter.get(
'/ratings',
requireAuth,
requireRole('LANDLORD', 'ADMIN'),
async (req: AuthedRequest, res: Response) => {
const userId = typeof req.query.userId === 'string' ? req.query.userId : undefined;
const ratings = await prisma.tenantRating.findMany({
where: userId ? { ratedUserId: userId } : undefined,
orderBy: { createdAt: 'desc' },
select: {
id: true,
score: true,
comment: true,
createdAt: true,
rater: { select: { id: true, fullName: true, role: true } },
ratedUser: { select: { id: true, fullName: true } },
},
});
const summaryByUser = new Map<string, { userId: string; fullName: string; count: number; avgScore: number }>();
for (const r of ratings) {
const entry = summaryByUser.get(r.ratedUser.id) || {
userId: r.ratedUser.id,
fullName: r.ratedUser.fullName,
count: 0,
avgScore: 0,
};
entry.avgScore = (entry.avgScore * entry.count + r.score) / (entry.count + 1);
entry.count += 1;
summaryByUser.set(r.ratedUser.id, entry);
}
res.status(200).json({ ratings, summary: Array.from(summaryByUser.values()) });
},
);