wg-verwaltung/backend/src/routes/inventory.ts
bernd 0179517979 Scope inventory list to own room + shared items for tenants
Tenants could previously see every other tenant's private room
inventory including purchase prices. Now matches the app's standard
visibility rule: own room + roomId=NULL (shared) items only; landlord/
admin unchanged (see everything).
2026-08-13 10:08:17 +00:00

136 lines
4.8 KiB
TypeScript

import { Router, Response } from 'express';
import { PrismaClient, ItemCondition } from '@prisma/client';
import { AuthedRequest, requireAuth, requireRole } from '../middleware/auth';
const prisma = new PrismaClient();
/**
* Router für die Inventarverwaltung (Möbel & Geräte je Zimmer bzw.
* Gemeinschaftsfläche). Anlegen/Ändern/Löschen ist Vermieter/Admin
* vorbehalten. Sichtbarkeit wie überall sonst: Mieter sehen nur Inventar
* ihres eigenen Zimmers plus echte Gemeinschaftsgegenstände (roomId NULL),
* nicht das private Zimmerinventar (inkl. Kaufpreis) anderer Mieter.
* Vermieter/Admin sehen alles.
*
* Pro Gegenstand können mehrere Fotos hinterlegt werden (photoUrls) — das
* Web-Dashboard sendet dafür clientseitig eingelesene Bilder als Data-URLs,
* es gibt keine separate Objekt-Storage-Anbindung in diesem Demo-Stand.
*/
export const inventoryRouter = Router();
const CONDITIONS = Object.values(ItemCondition);
const MAX_PHOTOS = 6;
const INVENTORY_SELECT = {
id: true,
itemName: true,
purchaseDate: true,
purchasePrice: true,
condition: true,
manualPdfUrl: true,
photoUrls: true,
createdAt: true,
room: { select: { id: true, roomNumber: true } },
};
function sanitizePhotoUrls(input: unknown): string[] {
if (!Array.isArray(input)) return [];
return input
.filter((u) => typeof u === 'string' && u.trim())
.slice(0, MAX_PHOTOS)
.map((u) => (u as string).trim());
}
inventoryRouter.get('/inventory', requireAuth, async (req: AuthedRequest, res: Response) => {
const isLandlord = req.user!.role === 'LANDLORD' || req.user!.role === 'ADMIN';
let where = {};
if (!isLandlord) {
const self = await prisma.user.findUnique({ where: { id: req.user!.id }, select: { roomId: true } });
where = { OR: [{ roomId: null }, { roomId: self?.roomId ?? '__none__' }] };
}
const items = await prisma.inventory.findMany({
where,
orderBy: { createdAt: 'desc' },
select: INVENTORY_SELECT,
});
res.status(200).json({ items });
});
inventoryRouter.post(
'/inventory',
requireAuth,
requireRole('LANDLORD', 'ADMIN'),
async (req: AuthedRequest, res: Response) => {
const { itemName, roomId, purchaseDate, purchasePrice, condition, manualPdfUrl, photoUrls } = req.body || {};
if (!itemName || typeof itemName !== 'string' || !itemName.trim()) {
return res.status(400).json({ error: 'itemName ist erforderlich' });
}
if (condition && !CONDITIONS.includes(condition)) {
return res.status(400).json({ error: `Ungültiger Zustand. Erlaubt: ${CONDITIONS.join(', ')}` });
}
if (roomId) {
const room = await prisma.room.findUnique({ where: { id: roomId } });
if (!room) return res.status(400).json({ error: 'Zimmer nicht gefunden' });
}
const item = await prisma.inventory.create({
data: {
itemName: itemName.trim().slice(0, 120),
roomId: roomId || null,
purchaseDate: purchaseDate && !Number.isNaN(Date.parse(purchaseDate)) ? new Date(purchaseDate) : null,
purchasePrice: purchasePrice !== undefined && purchasePrice !== null && purchasePrice !== '' ? Number(purchasePrice) : null,
condition: condition || ItemCondition.GOOD,
manualPdfUrl: typeof manualPdfUrl === 'string' && manualPdfUrl.trim() ? manualPdfUrl.trim() : null,
photoUrls: sanitizePhotoUrls(photoUrls),
},
select: INVENTORY_SELECT,
});
res.status(201).json({ item });
},
);
inventoryRouter.patch(
'/inventory/:id',
requireAuth,
requireRole('LANDLORD', 'ADMIN'),
async (req: AuthedRequest, res: Response) => {
const { condition, purchasePrice, manualPdfUrl, photoUrls } = req.body || {};
if (condition && !CONDITIONS.includes(condition)) {
return res.status(400).json({ error: `Ungültiger Zustand. Erlaubt: ${CONDITIONS.join(', ')}` });
}
try {
const item = await prisma.inventory.update({
where: { id: req.params.id },
data: {
condition: condition || undefined,
purchasePrice: purchasePrice !== undefined && purchasePrice !== null && purchasePrice !== '' ? Number(purchasePrice) : undefined,
manualPdfUrl: typeof manualPdfUrl === 'string' ? manualPdfUrl.trim() || null : undefined,
photoUrls: Array.isArray(photoUrls) ? sanitizePhotoUrls(photoUrls) : undefined,
},
select: INVENTORY_SELECT,
});
res.status(200).json({ item });
} catch {
res.status(404).json({ error: 'Inventar-Eintrag nicht gefunden' });
}
},
);
inventoryRouter.delete(
'/inventory/:id',
requireAuth,
requireRole('LANDLORD', 'ADMIN'),
async (req: AuthedRequest, res: Response) => {
try {
await prisma.inventory.delete({ where: { id: req.params.id } });
res.status(204).send();
} catch {
res.status(404).json({ error: 'Inventar-Eintrag nicht gefunden' });
}
},
);