From 791b9bb01eb5b59c4f353422844ba7d49e96f3fb Mon Sep 17 00:00:00 2001 From: bernd Date: Thu, 13 Aug 2026 07:39:32 +0000 Subject: [PATCH] Add peer rating system for tenants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/src/app.ts | 2 + backend/src/routes/ratings.ts | 98 ++++++++++++++++++++++++ prisma/schema.prisma | 28 +++++++ web-dashboard/index.html | 138 ++++++++++++++++++++++++++++++++++ 4 files changed, 266 insertions(+) create mode 100644 backend/src/routes/ratings.ts diff --git a/backend/src/app.ts b/backend/src/app.ts index f3ee9a5..0c882c3 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -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 })); diff --git a/backend/src/routes/ratings.ts b/backend/src/routes/ratings.ts new file mode 100644 index 0000000..3c1fd70 --- /dev/null +++ b/backend/src/routes/ratings.ts @@ -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(); + 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()) }); + }, +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index faa30bc..b7d4b87 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") +} diff --git a/web-dashboard/index.html b/web-dashboard/index.html index 01452b6..5ef28a3 100644 --- a/web-dashboard/index.html +++ b/web-dashboard/index.html @@ -767,6 +767,38 @@
+ +
+

Mieter-Bewertung

+
+ +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ +
@@ -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 => `${s.fullName}: Ø ${s.avgScore.toFixed(1)} (${s.count} Bewertung${s.count === 1 ? '' : 'en'})`) + .join('  ·  '); + 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 }) });