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.
This commit is contained in:
Giuseppe Lombardo 2026-08-13 07:39:32 +00:00
parent 4ba0100d31
commit 791b9bb01e
4 changed files with 266 additions and 0 deletions

View File

@ -16,6 +16,7 @@ import { documentsRouter } from './routes/documents';
import { contractsRouter } from './routes/contracts';
import { utilityStatementsRouter } from './routes/utilityStatements';
import { craftsmenRouter } from './routes/craftsmen';
import { ratingsRouter } from './routes/ratings';
export function createApp() {
const app = express();
@ -50,6 +51,7 @@ export function createApp() {
app.use('/v1', contractsRouter);
app.use('/v1', utilityStatementsRouter);
app.use('/v1', craftsmenRouter);
app.use('/v1', ratingsRouter);
app.get('/health', (_req, res) => res.status(200).json({ ok: true }));

View File

@ -0,0 +1,98 @@
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()) });
},
);

View File

@ -5,6 +5,12 @@
generator client {
provider = "prisma-client-js"
// Explizit gesetzt, da schema.prisma im Repo-Root liegt, aber node_modules
// nur in backend/ existiert: ohne dieses `output` sucht Prisma das
// "Projekt-Root" ausgehend vom Schema-Pfad nach oben, findet dort kein
// package.json und weicht auf einen unzuverlässigen Auto-Install-Fallback
// aus (schreibt dann z. B. nach /root/node_modules statt backend/).
output = "../backend/node_modules/@prisma/client"
}
datasource db {
@ -181,6 +187,8 @@ model User {
documentsUploaded Document[]
utilityStatementsCreated UtilityStatement[]
utilityStatementShares UtilityStatementShare[]
ratingsGiven TenantRating[] @relation("RatingsGiven")
ratingsReceived TenantRating[] @relation("RatingsReceived")
@@map("users")
}
@ -683,3 +691,23 @@ model Craftsman {
@@map("craftsmen")
}
// Peer-Bewertungssystem für (Zwischen-)Mieter: jeder Nutzer kann jeden Mieter
// bewerten (z. B. Sauberkeit, Zuverlässigkeit, WG-Verträglichkeit). Bewusst
// freistehend und nicht an einen Contract gekoppelt. Einsehbar nur für
// Vermieter/Admin (siehe ratings.ts) — Mitbewohner sehen keine Bewertungen,
// um WG-interne Konflikte durch offene Kritik zu vermeiden.
model TenantRating {
id String @id @default(uuid())
raterId String @map("rater_id")
rater User @relation("RatingsGiven", fields: [raterId], references: [id])
ratedUserId String @map("rated_user_id")
ratedUser User @relation("RatingsReceived", fields: [ratedUserId], references: [id])
score Int // 1 (sehr schlecht) bis 5 (sehr gut)
comment String? @db.Text
createdAt DateTime @default(now()) @map("created_at")
@@index([ratedUserId])
@@map("tenant_ratings")
}

View File

@ -767,6 +767,38 @@
</div>
<div class="ticket-list" id="craftsmenList"></div>
</section>
<section id="ratingsSection">
<h2>Mieter-Bewertung</h2>
<div class="invite-card" style="margin-bottom:16px;">
<div id="ratingFormError" style="display:none" class="form-error"></div>
<form id="ratingForm" class="invite-form-row" style="flex-wrap:wrap;">
<div class="field" style="flex:1 1 200px;">
<label for="ratingTargetUser">Mieter</label>
<select id="ratingTargetUser" required></select>
</div>
<div class="field" style="flex:1 1 140px;">
<label for="ratingScore">Bewertung</label>
<select id="ratingScore" required>
<option value="5">5 sehr gut</option>
<option value="4">4 gut</option>
<option value="3">3 okay</option>
<option value="2">2 schlecht</option>
<option value="1">1 sehr schlecht</option>
</select>
</div>
<div class="field" style="flex:2 1 240px;">
<label for="ratingComment">Kommentar (optional)</label>
<input type="text" id="ratingComment" placeholder="z. B. sehr zuverlässig, hält sich an den Putzplan" />
</div>
<button type="submit" class="btn-primary" style="width:auto" id="ratingSubmitBtn">Bewertung abgeben</button>
</form>
</div>
<div id="ratingsListWrap" style="display:none">
<p class="meta-text" style="margin-bottom:8px;">Nur für Vermieter/Admin sichtbar.</p>
<div class="ticket-list" id="ratingsList"></div>
</div>
</section>
</main>
</div>
@ -985,6 +1017,7 @@
if (isLandlord) initDocumentForm();
if (isLandlord) initUtilityStatementForm();
if (isLandlord) initCraftsmanForm();
initRatingForm();
loadData();
loadCleaningTasks();
@ -998,6 +1031,7 @@
loadNoticeDeadlines();
loadUtilityStatements();
loadCraftsmen();
loadRatings();
setInterval(loadData, REFRESH_INTERVAL_MS);
setInterval(loadCleaningTasks, REFRESH_INTERVAL_MS);
@ -1011,6 +1045,7 @@
setInterval(loadNoticeDeadlines, REFRESH_INTERVAL_MS);
setInterval(loadUtilityStatements, REFRESH_INTERVAL_MS);
setInterval(loadCraftsmen, REFRESH_INTERVAL_MS);
setInterval(loadRatings, REFRESH_INTERVAL_MS);
}
// ---------------------------------------------------------------------
@ -2685,6 +2720,109 @@
}
}
// ---------------------------------------------------------------------
// MIETER-BEWERTUNG (Peer-Bewertungssystem)
// ---------------------------------------------------------------------
async function initRatingForm() {
const select = document.getElementById('ratingTargetUser');
select.innerHTML = '';
try {
const data = await apiFetch('/ratings/tenants');
(data.tenants || [])
.filter(t => t.id !== currentUser.id)
.forEach(t => {
const opt = document.createElement('option');
opt.value = t.id;
opt.textContent = t.room ? `${t.fullName} (${t.room.roomNumber})` : t.fullName;
select.appendChild(opt);
});
if (!select.options.length) {
const opt = document.createElement('option');
opt.value = '';
opt.textContent = 'Keine anderen Mieter vorhanden';
select.appendChild(opt);
}
} catch (err) {
console.error('[ratings] Mieterliste laden fehlgeschlagen:', err.message);
}
const form = document.getElementById('ratingForm');
const errorBox = document.getElementById('ratingFormError');
form.onsubmit = async (e) => {
e.preventDefault();
errorBox.style.display = 'none';
const ratedUserId = select.value;
if (!ratedUserId) return;
const submitBtn = document.getElementById('ratingSubmitBtn');
submitBtn.disabled = true;
submitBtn.textContent = 'Speichert…';
try {
const score = Number(document.getElementById('ratingScore').value);
const comment = document.getElementById('ratingComment').value.trim();
await apiFetch('/ratings', {
method: 'POST',
body: JSON.stringify({ ratedUserId, score, comment: comment || undefined }),
});
document.getElementById('ratingComment').value = '';
loadRatings();
} catch (err) {
errorBox.textContent = err.message;
errorBox.style.display = 'block';
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Bewertung abgeben';
}
};
}
async function loadRatings() {
if (!currentUser) return;
const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN';
const wrap = document.getElementById('ratingsListWrap');
if (!isLandlord) {
wrap.style.display = 'none';
return;
}
wrap.style.display = 'block';
try {
const data = await apiFetch('/ratings');
renderRatings(data.ratings || [], data.summary || []);
} catch (err) {
console.error('[ratings] Laden fehlgeschlagen:', err.message);
}
}
function renderRatings(ratings, summary) {
const list = document.getElementById('ratingsList');
if (!list) return;
list.innerHTML = '';
if (summary.length) {
const summaryBox = el('div', 'meta-text', '');
summaryBox.style.marginBottom = '10px';
summaryBox.innerHTML = summary
.map(s => `<strong>${s.fullName}</strong>: Ø ${s.avgScore.toFixed(1)} (${s.count} Bewertung${s.count === 1 ? '' : 'en'})`)
.join(' &nbsp;·&nbsp; ');
list.appendChild(summaryBox);
}
if (!ratings.length) {
list.appendChild(el('div', 'empty-state', 'Noch keine Bewertungen erfasst.'));
return;
}
ratings.forEach(r => {
const row = el('div', 'ticket-row');
const left = el('div');
left.appendChild(el('div', 'title', `${r.ratedUser.fullName} · ${'★'.repeat(r.score)}${'☆'.repeat(5 - r.score)}`));
left.appendChild(el('div', 'meta-text',
`von ${r.rater.fullName} (${ROLE_LABEL[r.rater.role] || r.rater.role}) · ${new Date(r.createdAt).toLocaleDateString('de-DE')}` +
(r.comment ? ` · ${r.comment}` : '')));
row.appendChild(left);
list.appendChild(row);
});
}
async function assignCraftsmanToTicket(ticketId, craftsmanId) {
try {
await apiFetch(`/tickets/${ticketId}`, { method: 'PATCH', body: JSON.stringify({ craftsmanId: craftsmanId || null }) });