Dateien nach "backend/src/routes" hochladen
This commit is contained in:
parent
aad208d839
commit
e33aadaa90
190
backend/src/routes/expenses.ts
Normal file
190
backend/src/routes/expenses.ts
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
import { Router, Response } from 'express';
|
||||||
|
import { PrismaClient, ExpenseCategory, SettlementStatus } from '@prisma/client';
|
||||||
|
import { AuthedRequest, requireAuth } from '../middleware/auth';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Router für die WG-Kasse (Ausgaben-Teiler).
|
||||||
|
*
|
||||||
|
* Jeder (Mieter oder Vermieter) kann eine Auslage erfassen (z. B. Spülmittel,
|
||||||
|
* Klopapier). Der Betrag wird automatisch durch alle aktiven Mieter geteilt:
|
||||||
|
* der Zahler legt aus, alle ANDEREN Mieter bekommen einen offenen Anteil
|
||||||
|
* ("schulden" dem Zahler ihren Teil zurück). Die WG-Kasse ist als Ganzes für
|
||||||
|
* alle sichtbar (gemeinschaftliche Organisationsinfo, kein privates Datum).
|
||||||
|
*/
|
||||||
|
export const expensesRouter = Router();
|
||||||
|
|
||||||
|
const CATEGORIES = Object.values(ExpenseCategory);
|
||||||
|
|
||||||
|
const EXPENSE_SELECT = {
|
||||||
|
id: true,
|
||||||
|
amount: true,
|
||||||
|
description: true,
|
||||||
|
category: true,
|
||||||
|
receiptUrl: true,
|
||||||
|
createdAt: true,
|
||||||
|
payer: { select: { id: true, fullName: true } },
|
||||||
|
shares: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
shareAmount: true,
|
||||||
|
settlementStatus: true,
|
||||||
|
settledAt: true,
|
||||||
|
user: { select: { id: true, fullName: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// POST /expenses — Auslage erfassen, automatischer Split unter allen Mietern
|
||||||
|
// (außer dem Zahler selbst, der hat seinen Anteil ja bereits ausgelegt).
|
||||||
|
expensesRouter.post('/expenses', requireAuth, async (req: AuthedRequest, res: Response) => {
|
||||||
|
const { amount, description, category, receiptUrl } = req.body || {};
|
||||||
|
|
||||||
|
const parsedAmount = Number(amount);
|
||||||
|
if (!Number.isFinite(parsedAmount) || parsedAmount <= 0) {
|
||||||
|
return res.status(400).json({ error: 'amount muss eine positive Zahl sein' });
|
||||||
|
}
|
||||||
|
if (!description || typeof description !== 'string' || !description.trim()) {
|
||||||
|
return res.status(400).json({ error: 'description ist erforderlich' });
|
||||||
|
}
|
||||||
|
if (category && !CATEGORIES.includes(category)) {
|
||||||
|
return res.status(400).json({ error: `Ungültige Kategorie. Erlaubt: ${CATEGORIES.join(', ')}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenants = await prisma.user.findMany({
|
||||||
|
where: { role: 'TENANT' },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Anteil unter ALLEN Mitbewohnern (inkl. Zahler, falls dieser selbst Mieter
|
||||||
|
// ist) gleich aufteilen — nur die anderen bekommen einen offenen Anteil,
|
||||||
|
// der Zahler hat seinen Teil bereits durch die Auslage beglichen.
|
||||||
|
const participantCount = Math.max(tenants.length, 1);
|
||||||
|
const shareAmount = Math.round((parsedAmount / participantCount) * 100) / 100;
|
||||||
|
const owingTenants = tenants.filter((t) => t.id !== req.user!.id);
|
||||||
|
|
||||||
|
const expense = await prisma.expense.create({
|
||||||
|
data: {
|
||||||
|
payerId: req.user!.id,
|
||||||
|
amount: parsedAmount,
|
||||||
|
description: description.trim().slice(0, 200),
|
||||||
|
category: category || ExpenseCategory.HAUSHALT,
|
||||||
|
receiptUrl: typeof receiptUrl === 'string' && receiptUrl.trim() ? receiptUrl.trim() : null,
|
||||||
|
shares: {
|
||||||
|
create: owingTenants.map((t) => ({
|
||||||
|
userId: t.id,
|
||||||
|
shareAmount,
|
||||||
|
settlementStatus: SettlementStatus.OPEN,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: EXPENSE_SELECT,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({ expense });
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /expenses — alle Ausgaben der WG-Kasse (für alle sichtbar).
|
||||||
|
expensesRouter.get('/expenses', requireAuth, async (_req: AuthedRequest, res: Response) => {
|
||||||
|
const expenses = await prisma.expense.findMany({
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
select: EXPENSE_SELECT,
|
||||||
|
});
|
||||||
|
res.status(200).json({ expenses });
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /expenses/balance — Saldo pro Mieter: was er anderen schuldet (offene
|
||||||
|
// eigene Anteile) und was ihm andere noch schulden (offene Anteile an seinen
|
||||||
|
// eigenen Auslagen). Für alle sichtbar, da Teil der gemeinsamen WG-Kasse.
|
||||||
|
expensesRouter.get('/expenses/balance', requireAuth, async (_req: AuthedRequest, res: Response) => {
|
||||||
|
const [openSharesOwed, openSharesToReceive] = await Promise.all([
|
||||||
|
prisma.expenseShare.groupBy({
|
||||||
|
by: ['userId'],
|
||||||
|
where: { settlementStatus: SettlementStatus.OPEN },
|
||||||
|
_sum: { shareAmount: true },
|
||||||
|
}),
|
||||||
|
prisma.expenseShare.findMany({
|
||||||
|
where: { settlementStatus: SettlementStatus.OPEN },
|
||||||
|
select: { shareAmount: true, expense: { select: { payerId: true } } },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const receiveByPayer = new Map();
|
||||||
|
for (const share of openSharesToReceive) {
|
||||||
|
const payerId = share.expense.payerId;
|
||||||
|
receiveByPayer.set(payerId, (receiveByPayer.get(payerId) || 0) + Number(share.shareAmount));
|
||||||
|
}
|
||||||
|
|
||||||
|
const owedByUser = new Map();
|
||||||
|
for (const row of openSharesOwed) {
|
||||||
|
owedByUser.set(row.userId, Number(row._sum.shareAmount || 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
const userIds = new Set([...owedByUser.keys(), ...receiveByPayer.keys()]);
|
||||||
|
const users = await prisma.user.findMany({
|
||||||
|
where: { id: { in: Array.from(userIds) } },
|
||||||
|
select: { id: true, fullName: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const balance = users.map((u) => ({
|
||||||
|
user: u,
|
||||||
|
owes: Math.round((owedByUser.get(u.id) || 0) * 100) / 100,
|
||||||
|
isOwed: Math.round((receiveByPayer.get(u.id) || 0) * 100) / 100,
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.status(200).json({ balance });
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /expense-shares/:id — Anteil als beglichen markieren. Darf der
|
||||||
|
// Schuldner selbst (hat bezahlt), der Zahler der Auslage (hat Geld erhalten)
|
||||||
|
// oder Vermieter/Admin.
|
||||||
|
expensesRouter.patch('/expense-shares/:id', requireAuth, async (req: AuthedRequest, res: Response) => {
|
||||||
|
const share = await prisma.expenseShare.findUnique({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
include: { expense: { select: { payerId: true } } },
|
||||||
|
});
|
||||||
|
if (!share) return res.status(404).json({ error: 'Anteil nicht gefunden' });
|
||||||
|
|
||||||
|
const isLandlord = req.user!.role === 'LANDLORD' || req.user!.role === 'ADMIN';
|
||||||
|
const isDebtor = share.userId === req.user!.id;
|
||||||
|
const isPayer = share.expense.payerId === req.user!.id;
|
||||||
|
if (!isLandlord && !isDebtor && !isPayer) {
|
||||||
|
return res.status(403).json({ error: 'Keine Berechtigung, diesen Anteil zu ändern' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.expenseShare.update({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
data: { settlementStatus: SettlementStatus.SETTLED, settledAt: new Date() },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
shareAmount: true,
|
||||||
|
settlementStatus: true,
|
||||||
|
settledAt: true,
|
||||||
|
user: { select: { id: true, fullName: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({ share: updated });
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /expenses/:id — nur der Ersteller (bevor Anteile beglichen wurden)
|
||||||
|
// oder Vermieter/Admin.
|
||||||
|
expensesRouter.delete('/expenses/:id', requireAuth, async (req: AuthedRequest, res: Response) => {
|
||||||
|
const expense = await prisma.expense.findUnique({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
include: { shares: true },
|
||||||
|
});
|
||||||
|
if (!expense) return res.status(404).json({ error: 'Ausgabe nicht gefunden' });
|
||||||
|
|
||||||
|
const isLandlord = req.user!.role === 'LANDLORD' || req.user!.role === 'ADMIN';
|
||||||
|
if (!isLandlord && expense.payerId !== req.user!.id) {
|
||||||
|
return res.status(403).json({ error: 'Du kannst nur eigene Ausgaben löschen' });
|
||||||
|
}
|
||||||
|
if (!isLandlord && expense.shares.some((s) => s.settlementStatus === SettlementStatus.SETTLED)) {
|
||||||
|
return res.status(409).json({ error: 'Ausgabe hat bereits beglichene Anteile und kann nicht mehr gelöscht werden' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.expense.delete({ where: { id: req.params.id } });
|
||||||
|
res.status(204).send();
|
||||||
|
});
|
||||||
96
backend/src/routes/guestCodes.ts
Normal file
96
backend/src/routes/guestCodes.ts
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import { Router, Response } from 'express';
|
||||||
|
import { PrismaClient, GuestCodeStatus } from '@prisma/client';
|
||||||
|
import { AuthedRequest, requireAuth } from '../middleware/auth';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Router für Smart-Lock-Gastcodes (Nuki/Tuya). Jeder eingeloggte Nutzer
|
||||||
|
* (Mieter oder Vermieter) kann einen zeitlich begrenzten Gäste-Code
|
||||||
|
* generieren. Ein Mieter sieht/verwaltet nur seine EIGENEN ausgestellten
|
||||||
|
* Codes (wer wann Besuch bekommt, ist privat) — Vermieter/Admin sehen alle.
|
||||||
|
*/
|
||||||
|
export const guestCodesRouter = Router();
|
||||||
|
|
||||||
|
const DEFAULT_LOCK_DEVICE_ID = 'front-door-01';
|
||||||
|
|
||||||
|
function generateCode(): string {
|
||||||
|
return String(Math.floor(100000 + Math.random() * 900000)); // 6-stelliger PIN
|
||||||
|
}
|
||||||
|
|
||||||
|
const GUEST_CODE_SELECT = {
|
||||||
|
id: true,
|
||||||
|
code: true,
|
||||||
|
lockDeviceId: true,
|
||||||
|
validFrom: true,
|
||||||
|
validUntil: true,
|
||||||
|
status: true,
|
||||||
|
createdAt: true,
|
||||||
|
issuedBy: { select: { id: true, fullName: true } },
|
||||||
|
};
|
||||||
|
|
||||||
|
guestCodesRouter.post('/guest-codes', requireAuth, async (req: AuthedRequest, res: Response) => {
|
||||||
|
const { validFrom, validUntil, lockDeviceId } = req.body || {};
|
||||||
|
|
||||||
|
if (!validFrom || Number.isNaN(Date.parse(validFrom))) {
|
||||||
|
return res.status(400).json({ error: 'validFrom (Datum/Zeit) ist erforderlich' });
|
||||||
|
}
|
||||||
|
if (!validUntil || Number.isNaN(Date.parse(validUntil))) {
|
||||||
|
return res.status(400).json({ error: 'validUntil (Datum/Zeit) ist erforderlich' });
|
||||||
|
}
|
||||||
|
if (new Date(validUntil) <= new Date(validFrom)) {
|
||||||
|
return res.status(400).json({ error: 'validUntil muss nach validFrom liegen' });
|
||||||
|
}
|
||||||
|
|
||||||
|
let code = generateCode();
|
||||||
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||||
|
const existing = await prisma.guestCode.findUnique({ where: { code } });
|
||||||
|
if (!existing) break;
|
||||||
|
code = generateCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
const guestCode = await prisma.guestCode.create({
|
||||||
|
data: {
|
||||||
|
issuedById: req.user!.id,
|
||||||
|
code,
|
||||||
|
lockDeviceId: typeof lockDeviceId === 'string' && lockDeviceId.trim() ? lockDeviceId.trim() : DEFAULT_LOCK_DEVICE_ID,
|
||||||
|
validFrom: new Date(validFrom),
|
||||||
|
validUntil: new Date(validUntil),
|
||||||
|
status: GuestCodeStatus.ACTIVE,
|
||||||
|
},
|
||||||
|
select: GUEST_CODE_SELECT,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({ guestCode });
|
||||||
|
});
|
||||||
|
|
||||||
|
guestCodesRouter.get('/guest-codes', requireAuth, async (req: AuthedRequest, res: Response) => {
|
||||||
|
const isLandlord = req.user!.role === 'LANDLORD' || req.user!.role === 'ADMIN';
|
||||||
|
const where: Record<string, unknown> = {};
|
||||||
|
if (!isLandlord) where.issuedById = req.user!.id;
|
||||||
|
|
||||||
|
const guestCodes = await prisma.guestCode.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { validFrom: 'desc' },
|
||||||
|
select: GUEST_CODE_SELECT,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({ guestCodes });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Widerrufen — eigener Code oder Vermieter/Admin.
|
||||||
|
guestCodesRouter.delete('/guest-codes/:id', requireAuth, async (req: AuthedRequest, res: Response) => {
|
||||||
|
const guestCode = await prisma.guestCode.findUnique({ where: { id: req.params.id } });
|
||||||
|
if (!guestCode) return res.status(404).json({ error: 'Gästecode nicht gefunden' });
|
||||||
|
|
||||||
|
const isLandlord = req.user!.role === 'LANDLORD' || req.user!.role === 'ADMIN';
|
||||||
|
if (!isLandlord && guestCode.issuedById !== req.user!.id) {
|
||||||
|
return res.status(403).json({ error: 'Du kannst nur deine eigenen Gästecodes widerrufen' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.guestCode.update({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
data: { status: GuestCodeStatus.REVOKED },
|
||||||
|
});
|
||||||
|
res.status(204).send();
|
||||||
|
});
|
||||||
152
backend/src/routes/handoverProtocols.ts
Normal file
152
backend/src/routes/handoverProtocols.ts
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
import { Router, Response } from 'express';
|
||||||
|
import { PrismaClient, HandoverType, ItemCondition } from '@prisma/client';
|
||||||
|
import { AuthedRequest, requireAuth, requireRole } from '../middleware/auth';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Router für digitale Übergabeprotokolle (Ein-/Auszug): Zustand des
|
||||||
|
* Inventars je Zimmer + Zählerstände, mit optionaler digitaler Signatur.
|
||||||
|
*
|
||||||
|
* Zugriffsregel: nur Vermieter/Admin legen Protokolle an. Ein Mieter sieht
|
||||||
|
* ausschließlich seine EIGENEN Protokolle (sein Ein-/Auszug ist ein privates
|
||||||
|
* Dokument) — Vermieter/Admin sehen alle.
|
||||||
|
*/
|
||||||
|
export const handoverProtocolsRouter = Router();
|
||||||
|
|
||||||
|
const TYPES = Object.values(HandoverType);
|
||||||
|
const CONDITIONS = Object.values(ItemCondition);
|
||||||
|
|
||||||
|
const PROTOCOL_SELECT = {
|
||||||
|
id: true,
|
||||||
|
type: true,
|
||||||
|
protocolDate: true,
|
||||||
|
signatureUrl: true,
|
||||||
|
pdfUrl: true,
|
||||||
|
createdAt: true,
|
||||||
|
user: { select: { id: true, fullName: true } },
|
||||||
|
contract: { select: { id: true, room: { select: { id: true, roomNumber: true } } } },
|
||||||
|
items: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
condition: true,
|
||||||
|
note: true,
|
||||||
|
photoUrl: true,
|
||||||
|
inventory: { select: { id: true, itemName: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
meterReadings: {
|
||||||
|
select: { id: true, meterType: true, value: true, readAt: true, photoUrl: true },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Vermieter/Admin: Liste aktiver Mietverträge (für die Auswahl im Formular).
|
||||||
|
handoverProtocolsRouter.get(
|
||||||
|
'/contracts',
|
||||||
|
requireAuth,
|
||||||
|
requireRole('LANDLORD', 'ADMIN'),
|
||||||
|
async (_req: AuthedRequest, res: Response) => {
|
||||||
|
const contracts = await prisma.contract.findMany({
|
||||||
|
where: { isActive: true },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
user: { select: { id: true, fullName: true } },
|
||||||
|
room: { select: { id: true, roomNumber: true } },
|
||||||
|
},
|
||||||
|
orderBy: { startDate: 'desc' },
|
||||||
|
});
|
||||||
|
res.status(200).json({ contracts });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Vermieter/Admin: Übergabeprotokoll anlegen, inkl. Item-Zuständen und
|
||||||
|
// Zählerständen in einem Aufruf (verschachteltes Anlegen).
|
||||||
|
handoverProtocolsRouter.post(
|
||||||
|
'/handover-protocols',
|
||||||
|
requireAuth,
|
||||||
|
requireRole('LANDLORD', 'ADMIN'),
|
||||||
|
async (req: AuthedRequest, res: Response) => {
|
||||||
|
const { contractId, type, protocolDate, signatureUrl, items, meterReadings } = req.body || {};
|
||||||
|
|
||||||
|
if (!contractId || typeof contractId !== 'string') {
|
||||||
|
return res.status(400).json({ error: 'contractId ist erforderlich' });
|
||||||
|
}
|
||||||
|
if (!type || !TYPES.includes(type)) {
|
||||||
|
return res.status(400).json({ error: `Ungültiger Typ. Erlaubt: ${TYPES.join(', ')}` });
|
||||||
|
}
|
||||||
|
if (!protocolDate || Number.isNaN(Date.parse(protocolDate))) {
|
||||||
|
return res.status(400).json({ error: 'protocolDate (Datum) ist erforderlich' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const contract = await prisma.contract.findUnique({ where: { id: contractId } });
|
||||||
|
if (!contract) return res.status(400).json({ error: 'Mietvertrag nicht gefunden' });
|
||||||
|
|
||||||
|
const itemsData = Array.isArray(items)
|
||||||
|
? items
|
||||||
|
.filter((i: any) => i && CONDITIONS.includes(i.condition))
|
||||||
|
.map((i: any) => ({
|
||||||
|
inventoryId: typeof i.inventoryId === 'string' && i.inventoryId ? i.inventoryId : null,
|
||||||
|
condition: i.condition,
|
||||||
|
note: typeof i.note === 'string' && i.note.trim() ? i.note.trim().slice(0, 280) : null,
|
||||||
|
photoUrl: typeof i.photoUrl === 'string' && i.photoUrl.trim() ? i.photoUrl.trim() : null,
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const metersData = Array.isArray(meterReadings)
|
||||||
|
? meterReadings
|
||||||
|
.filter((m: any) => m && m.meterType && m.value !== undefined && m.value !== null && m.value !== '')
|
||||||
|
.map((m: any) => ({
|
||||||
|
meterType: String(m.meterType).slice(0, 40),
|
||||||
|
value: Number(m.value),
|
||||||
|
readAt: m.readAt && !Number.isNaN(Date.parse(m.readAt)) ? new Date(m.readAt) : new Date(protocolDate),
|
||||||
|
photoUrl: typeof m.photoUrl === 'string' && m.photoUrl.trim() ? m.photoUrl.trim() : null,
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const protocol = await prisma.handoverProtocol.create({
|
||||||
|
data: {
|
||||||
|
contractId,
|
||||||
|
userId: contract.userId,
|
||||||
|
type,
|
||||||
|
protocolDate: new Date(protocolDate),
|
||||||
|
signatureUrl: typeof signatureUrl === 'string' && signatureUrl.trim() ? signatureUrl.trim() : null,
|
||||||
|
items: { create: itemsData },
|
||||||
|
meterReadings: { create: metersData },
|
||||||
|
},
|
||||||
|
select: PROTOCOL_SELECT,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({ protocol });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Übergabeprotokolle auflisten — Mieter sehen nur ihre eigenen, Vermieter/Admin alle.
|
||||||
|
handoverProtocolsRouter.get('/handover-protocols', requireAuth, async (req: AuthedRequest, res: Response) => {
|
||||||
|
const isLandlord = req.user!.role === 'LANDLORD' || req.user!.role === 'ADMIN';
|
||||||
|
const where: Record<string, unknown> = {};
|
||||||
|
if (!isLandlord) where.userId = req.user!.id;
|
||||||
|
|
||||||
|
const protocols = await prisma.handoverProtocol.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { protocolDate: 'desc' },
|
||||||
|
select: PROTOCOL_SELECT,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({ protocols });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Einzelnes Protokoll — gleiche Zugriffsregel wie die Liste.
|
||||||
|
handoverProtocolsRouter.get('/handover-protocols/:id', requireAuth, async (req: AuthedRequest, res: Response) => {
|
||||||
|
const protocol = await prisma.handoverProtocol.findUnique({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
select: PROTOCOL_SELECT,
|
||||||
|
});
|
||||||
|
if (!protocol) return res.status(404).json({ error: 'Übergabeprotokoll nicht gefunden' });
|
||||||
|
|
||||||
|
const isLandlord = req.user!.role === 'LANDLORD' || req.user!.role === 'ADMIN';
|
||||||
|
if (!isLandlord && protocol.user.id !== req.user!.id) {
|
||||||
|
return res.status(403).json({ error: 'Kein Zugriff auf dieses Übergabeprotokoll' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({ protocol });
|
||||||
|
});
|
||||||
125
backend/src/routes/inventory.ts
Normal file
125
backend/src/routes/inventory.ts
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
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; die Liste selbst ist für alle eingeloggten Nutzer sichtbar
|
||||||
|
* (z. B. damit Mieter nachschauen können, was zu welchem Zimmer gehört).
|
||||||
|
*
|
||||||
|
* 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 items = await prisma.inventory.findMany({
|
||||||
|
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' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
160
backend/src/routes/storage.ts
Normal file
160
backend/src/routes/storage.ts
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
import { Router, Response } from 'express';
|
||||||
|
import { PrismaClient, StorageLocation, TrashType } from '@prisma/client';
|
||||||
|
import { AuthedRequest, requireAuth, requireRole } from '../middleware/auth';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Router für den digitalen Küchen-/Schrankplaner (Fach-Zuordnung Kühlschrank
|
||||||
|
* & Küchenschränke) sowie den Müll-Kalender. Beides ist gemeinschaftliche
|
||||||
|
* Organisationsinfo und daher für alle eingeloggten Nutzer sichtbar.
|
||||||
|
*/
|
||||||
|
export const storageRouter = Router();
|
||||||
|
|
||||||
|
const LOCATIONS = Object.values(StorageLocation);
|
||||||
|
const TRASH_TYPES = Object.values(TrashType);
|
||||||
|
|
||||||
|
const SLOT_SELECT = {
|
||||||
|
id: true,
|
||||||
|
location: true,
|
||||||
|
label: true,
|
||||||
|
user: { select: { id: true, fullName: true } },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Vermieter/Admin legt ein neues Fach an (einmalige Einrichtung).
|
||||||
|
storageRouter.post(
|
||||||
|
'/storage-slots',
|
||||||
|
requireAuth,
|
||||||
|
requireRole('LANDLORD', 'ADMIN'),
|
||||||
|
async (req: AuthedRequest, res: Response) => {
|
||||||
|
const { location, label } = req.body || {};
|
||||||
|
if (!location || !LOCATIONS.includes(location)) {
|
||||||
|
return res.status(400).json({ error: `Ungültiger Ort. Erlaubt: ${LOCATIONS.join(', ')}` });
|
||||||
|
}
|
||||||
|
if (!label || typeof label !== 'string' || !label.trim()) {
|
||||||
|
return res.status(400).json({ error: 'label ist erforderlich' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const slot = await prisma.storageSlot.create({
|
||||||
|
data: { location, label: label.trim().slice(0, 80) },
|
||||||
|
select: SLOT_SELECT,
|
||||||
|
});
|
||||||
|
res.status(201).json({ slot });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
storageRouter.get('/storage-slots', requireAuth, async (_req: AuthedRequest, res: Response) => {
|
||||||
|
const slots = await prisma.storageSlot.findMany({
|
||||||
|
orderBy: [{ location: 'asc' }, { label: 'asc' }],
|
||||||
|
select: SLOT_SELECT,
|
||||||
|
});
|
||||||
|
res.status(200).json({ slots });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fach beanspruchen (für sich selbst) oder freigeben (userId: null).
|
||||||
|
// Vermieter/Admin dürfen ein Fach für jeden Mieter zuweisen/freigeben.
|
||||||
|
storageRouter.patch('/storage-slots/:id', requireAuth, async (req: AuthedRequest, res: Response) => {
|
||||||
|
const slot = await prisma.storageSlot.findUnique({ where: { id: req.params.id } });
|
||||||
|
if (!slot) return res.status(404).json({ error: 'Fach nicht gefunden' });
|
||||||
|
|
||||||
|
const isLandlord = req.user!.role === 'LANDLORD' || req.user!.role === 'ADMIN';
|
||||||
|
const { userId } = req.body || {};
|
||||||
|
|
||||||
|
let nextUserId: string | null;
|
||||||
|
if (userId === null || userId === '') {
|
||||||
|
// Freigeben: eigener Slot oder Vermieter/Admin.
|
||||||
|
if (!isLandlord && slot.userId !== req.user!.id) {
|
||||||
|
return res.status(403).json({ error: 'Du kannst nur dein eigenes Fach freigeben' });
|
||||||
|
}
|
||||||
|
nextUserId = null;
|
||||||
|
} else if (isLandlord) {
|
||||||
|
if (typeof userId !== 'string') return res.status(400).json({ error: 'userId ist erforderlich' });
|
||||||
|
nextUserId = userId;
|
||||||
|
} else {
|
||||||
|
// Mieter dürfen nur sich selbst eintragen, und nur ein freies Fach beanspruchen.
|
||||||
|
if (slot.userId && slot.userId !== req.user!.id) {
|
||||||
|
return res.status(409).json({ error: 'Dieses Fach ist bereits belegt' });
|
||||||
|
}
|
||||||
|
nextUserId = req.user!.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.storageSlot.update({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
data: { userId: nextUserId },
|
||||||
|
select: SLOT_SELECT,
|
||||||
|
});
|
||||||
|
res.status(200).json({ slot: updated });
|
||||||
|
});
|
||||||
|
|
||||||
|
storageRouter.delete(
|
||||||
|
'/storage-slots/:id',
|
||||||
|
requireAuth,
|
||||||
|
requireRole('LANDLORD', 'ADMIN'),
|
||||||
|
async (req: AuthedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
await prisma.storageSlot.delete({ where: { id: req.params.id } });
|
||||||
|
res.status(204).send();
|
||||||
|
} catch {
|
||||||
|
res.status(404).json({ error: 'Fach nicht gefunden' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// MÜLL-KALENDER
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const TRASH_SELECT = { id: true, type: true, date: true };
|
||||||
|
|
||||||
|
storageRouter.post(
|
||||||
|
'/trash-schedule',
|
||||||
|
requireAuth,
|
||||||
|
requireRole('LANDLORD', 'ADMIN'),
|
||||||
|
async (req: AuthedRequest, res: Response) => {
|
||||||
|
const { type, date } = req.body || {};
|
||||||
|
if (!type || !TRASH_TYPES.includes(type)) {
|
||||||
|
return res.status(400).json({ error: `Ungültiger Mülltyp. Erlaubt: ${TRASH_TYPES.join(', ')}` });
|
||||||
|
}
|
||||||
|
if (!date || Number.isNaN(Date.parse(date))) {
|
||||||
|
return res.status(400).json({ error: 'date ist erforderlich' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = await prisma.trashSchedule.create({
|
||||||
|
data: { type, date: new Date(date) },
|
||||||
|
select: TRASH_SELECT,
|
||||||
|
});
|
||||||
|
res.status(201).json({ entry });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
storageRouter.get('/trash-schedule', requireAuth, async (req: AuthedRequest, res: Response) => {
|
||||||
|
const { from, to } = req.query;
|
||||||
|
const fromDate = typeof from === 'string' && !Number.isNaN(Date.parse(from)) ? new Date(from) : undefined;
|
||||||
|
const toDate = typeof to === 'string' && !Number.isNaN(Date.parse(to)) ? new Date(to) : undefined;
|
||||||
|
|
||||||
|
const where: Record<string, unknown> = {};
|
||||||
|
if (fromDate || toDate) {
|
||||||
|
where.date = { ...(fromDate ? { gte: fromDate } : {}), ...(toDate ? { lte: toDate } : {}) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = await prisma.trashSchedule.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { date: 'asc' },
|
||||||
|
select: TRASH_SELECT,
|
||||||
|
});
|
||||||
|
res.status(200).json({ entries });
|
||||||
|
});
|
||||||
|
|
||||||
|
storageRouter.delete(
|
||||||
|
'/trash-schedule/:id',
|
||||||
|
requireAuth,
|
||||||
|
requireRole('LANDLORD', 'ADMIN'),
|
||||||
|
async (req: AuthedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
await prisma.trashSchedule.delete({ where: { id: req.params.id } });
|
||||||
|
res.status(204).send();
|
||||||
|
} catch {
|
||||||
|
res.status(404).json({ error: 'Eintrag nicht gefunden' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
Loading…
Reference in New Issue
Block a user