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:
parent
4ba0100d31
commit
791b9bb01e
@ -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 }));
|
||||
|
||||
|
||||
98
backend/src/routes/ratings.ts
Normal file
98
backend/src/routes/ratings.ts
Normal 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()) });
|
||||
},
|
||||
);
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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(' · ');
|
||||
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 }) });
|
||||
|
||||
Loading…
Reference in New Issue
Block a user