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 { contractsRouter } from './routes/contracts';
|
||||||
import { utilityStatementsRouter } from './routes/utilityStatements';
|
import { utilityStatementsRouter } from './routes/utilityStatements';
|
||||||
import { craftsmenRouter } from './routes/craftsmen';
|
import { craftsmenRouter } from './routes/craftsmen';
|
||||||
|
import { ratingsRouter } from './routes/ratings';
|
||||||
|
|
||||||
export function createApp() {
|
export function createApp() {
|
||||||
const app = express();
|
const app = express();
|
||||||
@ -50,6 +51,7 @@ export function createApp() {
|
|||||||
app.use('/v1', contractsRouter);
|
app.use('/v1', contractsRouter);
|
||||||
app.use('/v1', utilityStatementsRouter);
|
app.use('/v1', utilityStatementsRouter);
|
||||||
app.use('/v1', craftsmenRouter);
|
app.use('/v1', craftsmenRouter);
|
||||||
|
app.use('/v1', ratingsRouter);
|
||||||
|
|
||||||
app.get('/health', (_req, res) => res.status(200).json({ ok: true }));
|
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 {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
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 {
|
datasource db {
|
||||||
@ -181,6 +187,8 @@ model User {
|
|||||||
documentsUploaded Document[]
|
documentsUploaded Document[]
|
||||||
utilityStatementsCreated UtilityStatement[]
|
utilityStatementsCreated UtilityStatement[]
|
||||||
utilityStatementShares UtilityStatementShare[]
|
utilityStatementShares UtilityStatementShare[]
|
||||||
|
ratingsGiven TenantRating[] @relation("RatingsGiven")
|
||||||
|
ratingsReceived TenantRating[] @relation("RatingsReceived")
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
@ -683,3 +691,23 @@ model Craftsman {
|
|||||||
|
|
||||||
@@map("craftsmen")
|
@@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>
|
||||||
<div class="ticket-list" id="craftsmenList"></div>
|
<div class="ticket-list" id="craftsmenList"></div>
|
||||||
</section>
|
</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>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -985,6 +1017,7 @@
|
|||||||
if (isLandlord) initDocumentForm();
|
if (isLandlord) initDocumentForm();
|
||||||
if (isLandlord) initUtilityStatementForm();
|
if (isLandlord) initUtilityStatementForm();
|
||||||
if (isLandlord) initCraftsmanForm();
|
if (isLandlord) initCraftsmanForm();
|
||||||
|
initRatingForm();
|
||||||
|
|
||||||
loadData();
|
loadData();
|
||||||
loadCleaningTasks();
|
loadCleaningTasks();
|
||||||
@ -998,6 +1031,7 @@
|
|||||||
loadNoticeDeadlines();
|
loadNoticeDeadlines();
|
||||||
loadUtilityStatements();
|
loadUtilityStatements();
|
||||||
loadCraftsmen();
|
loadCraftsmen();
|
||||||
|
loadRatings();
|
||||||
|
|
||||||
setInterval(loadData, REFRESH_INTERVAL_MS);
|
setInterval(loadData, REFRESH_INTERVAL_MS);
|
||||||
setInterval(loadCleaningTasks, REFRESH_INTERVAL_MS);
|
setInterval(loadCleaningTasks, REFRESH_INTERVAL_MS);
|
||||||
@ -1011,6 +1045,7 @@
|
|||||||
setInterval(loadNoticeDeadlines, REFRESH_INTERVAL_MS);
|
setInterval(loadNoticeDeadlines, REFRESH_INTERVAL_MS);
|
||||||
setInterval(loadUtilityStatements, REFRESH_INTERVAL_MS);
|
setInterval(loadUtilityStatements, REFRESH_INTERVAL_MS);
|
||||||
setInterval(loadCraftsmen, 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) {
|
async function assignCraftsmanToTicket(ticketId, craftsmanId) {
|
||||||
try {
|
try {
|
||||||
await apiFetch(`/tickets/${ticketId}`, { method: 'PATCH', body: JSON.stringify({ craftsmanId: craftsmanId || null }) });
|
await apiFetch(`/tickets/${ticketId}`, { method: 'PATCH', body: JSON.stringify({ craftsmanId: craftsmanId || null }) });
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user