Add tickets API with room-scoped access control for tenants
This commit is contained in:
parent
6d8cbd7f42
commit
ffea782e96
170
backend/src/routes/tickets.ts
Normal file
170
backend/src/routes/tickets.ts
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
import { Router, Response } from 'express';
|
||||||
|
import { PrismaClient, TicketCategory, TicketPriority, TicketStatus } from '@prisma/client';
|
||||||
|
import { AuthedRequest, requireAuth, requireRole } from '../middleware/auth';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Router für Schadensmeldungen/Tickets.
|
||||||
|
*
|
||||||
|
* Zugriffsregel (Kern der Berechtigungslogik): ein Mieter darf nur Tickets sehen,
|
||||||
|
* die er selbst erstellt hat, die einen Gemeinschaftsbereich betreffen (roomId
|
||||||
|
* = null, z. B. Küche/Bad/Allgemein) oder die sein eigenes Zimmer betreffen.
|
||||||
|
* Tickets zu den privaten Zimmern anderer Mieter sind für ihn unsichtbar.
|
||||||
|
* Vermieter/Admin sehen alles.
|
||||||
|
*/
|
||||||
|
export const ticketsRouter = Router();
|
||||||
|
|
||||||
|
const CATEGORIES = Object.values(TicketCategory);
|
||||||
|
const PRIORITIES = Object.values(TicketPriority);
|
||||||
|
|
||||||
|
async function getOwnRoomId(userId: string): Promise<string | null> {
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: userId }, select: { roomId: true } });
|
||||||
|
return user?.roomId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TICKET_SELECT = {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
description: true,
|
||||||
|
category: true,
|
||||||
|
priority: true,
|
||||||
|
status: true,
|
||||||
|
imageUrls: true,
|
||||||
|
createdAt: true,
|
||||||
|
resolvedAt: true,
|
||||||
|
room: { select: { id: true, roomNumber: true } },
|
||||||
|
creator: { select: { id: true, fullName: true } },
|
||||||
|
assignee: { select: { id: true, fullName: true } },
|
||||||
|
};
|
||||||
|
|
||||||
|
ticketsRouter.post('/tickets', requireAuth, async (req: AuthedRequest, res: Response) => {
|
||||||
|
const { title, description, category, priority, roomId } = req.body || {};
|
||||||
|
|
||||||
|
if (!title || typeof title !== 'string' || !title.trim()) {
|
||||||
|
return res.status(400).json({ error: 'Titel ist erforderlich' });
|
||||||
|
}
|
||||||
|
if (!description || typeof description !== 'string' || !description.trim()) {
|
||||||
|
return res.status(400).json({ error: 'Beschreibung ist erforderlich' });
|
||||||
|
}
|
||||||
|
if (!category || !CATEGORIES.includes(category)) {
|
||||||
|
return res.status(400).json({ error: `Ungültige Kategorie. Erlaubt: ${CATEGORIES.join(', ')}` });
|
||||||
|
}
|
||||||
|
if (priority && !PRIORITIES.includes(priority)) {
|
||||||
|
return res.status(400).json({ error: `Ungültige Priorität. Erlaubt: ${PRIORITIES.join(', ')}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLandlord = req.user!.role === 'LANDLORD' || req.user!.role === 'ADMIN';
|
||||||
|
let targetRoomId: string | null = roomId || null;
|
||||||
|
|
||||||
|
if (!isLandlord) {
|
||||||
|
// Mieter dürfen nur für ihr eigenes Zimmer oder allgemein (Gemeinschaftsbereich) melden.
|
||||||
|
const ownRoomId = await getOwnRoomId(req.user!.id);
|
||||||
|
if (targetRoomId && targetRoomId !== ownRoomId) {
|
||||||
|
return res.status(403).json({ error: 'Du kannst nur für dein eigenes Zimmer oder den Gemeinschaftsbereich melden' });
|
||||||
|
}
|
||||||
|
} else if (targetRoomId) {
|
||||||
|
const room = await prisma.room.findUnique({ where: { id: targetRoomId } });
|
||||||
|
if (!room) return res.status(400).json({ error: 'Zimmer nicht gefunden' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ticket = await prisma.ticket.create({
|
||||||
|
data: {
|
||||||
|
creatorId: req.user!.id,
|
||||||
|
roomId: targetRoomId,
|
||||||
|
title: title.trim(),
|
||||||
|
description: description.trim(),
|
||||||
|
category,
|
||||||
|
priority: priority || TicketPriority.MEDIUM,
|
||||||
|
status: TicketStatus.OPEN,
|
||||||
|
},
|
||||||
|
select: TICKET_SELECT,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({ ticket });
|
||||||
|
});
|
||||||
|
|
||||||
|
ticketsRouter.get('/tickets', requireAuth, async (req: AuthedRequest, res: Response) => {
|
||||||
|
const isLandlord = req.user!.role === 'LANDLORD' || req.user!.role === 'ADMIN';
|
||||||
|
const statusFilter = typeof req.query.status === 'string' ? req.query.status : undefined;
|
||||||
|
|
||||||
|
const where: Record<string, unknown> = {};
|
||||||
|
if (statusFilter && (Object.values(TicketStatus) as string[]).includes(statusFilter)) {
|
||||||
|
where.status = statusFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLandlord) {
|
||||||
|
const ownRoomId = await getOwnRoomId(req.user!.id);
|
||||||
|
where.OR = [
|
||||||
|
{ creatorId: req.user!.id },
|
||||||
|
{ roomId: null },
|
||||||
|
...(ownRoomId ? [{ roomId: ownRoomId }] : []),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const tickets = await prisma.ticket.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
select: TICKET_SELECT,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({ tickets });
|
||||||
|
});
|
||||||
|
|
||||||
|
ticketsRouter.patch(
|
||||||
|
'/tickets/:id',
|
||||||
|
requireAuth,
|
||||||
|
requireRole('LANDLORD', 'ADMIN'),
|
||||||
|
async (req: AuthedRequest, res: Response) => {
|
||||||
|
const { status, priority, assigneeId } = req.body || {};
|
||||||
|
const data: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
if (status !== undefined) {
|
||||||
|
if (!(Object.values(TicketStatus) as string[]).includes(status)) {
|
||||||
|
return res.status(400).json({ error: `Ungültiger Status. Erlaubt: ${Object.values(TicketStatus).join(', ')}` });
|
||||||
|
}
|
||||||
|
data.status = status;
|
||||||
|
data.resolvedAt = status === 'RESOLVED' || status === 'CLOSED' ? new Date() : null;
|
||||||
|
}
|
||||||
|
if (priority !== undefined) {
|
||||||
|
if (!PRIORITIES.includes(priority)) {
|
||||||
|
return res.status(400).json({ error: `Ungültige Priorität. Erlaubt: ${PRIORITIES.join(', ')}` });
|
||||||
|
}
|
||||||
|
data.priority = priority;
|
||||||
|
}
|
||||||
|
if (assigneeId !== undefined) {
|
||||||
|
data.assigneeId = assigneeId || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(data).length === 0) {
|
||||||
|
return res.status(400).json({ error: 'Keine Änderungen übergeben' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ticket = await prisma.ticket.update({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
data,
|
||||||
|
select: TICKET_SELECT,
|
||||||
|
});
|
||||||
|
res.status(200).json({ ticket });
|
||||||
|
} catch {
|
||||||
|
res.status(404).json({ error: 'Ticket nicht gefunden' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mieter dürfen ihre eigene Meldung zurückziehen, solange sie noch nicht bearbeitet wird.
|
||||||
|
ticketsRouter.delete('/tickets/:id', requireAuth, async (req: AuthedRequest, res: Response) => {
|
||||||
|
const ticket = await prisma.ticket.findUnique({ where: { id: req.params.id } });
|
||||||
|
if (!ticket) return res.status(404).json({ error: 'Ticket nicht gefunden' });
|
||||||
|
|
||||||
|
const isLandlord = req.user!.role === 'LANDLORD' || req.user!.role === 'ADMIN';
|
||||||
|
const isOwnOpenTicket = ticket.creatorId === req.user!.id && ticket.status === 'OPEN';
|
||||||
|
|
||||||
|
if (!isLandlord && !isOwnOpenTicket) {
|
||||||
|
return res.status(403).json({ error: 'Keine Berechtigung, dieses Ticket zurückzuziehen' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.ticket.delete({ where: { id: req.params.id } });
|
||||||
|
res.status(204).send();
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user