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