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.
99 lines
3.6 KiB
TypeScript
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()) });
|
|
},
|
|
);
|