diff --git a/backend/src/routes/expenses.ts b/backend/src/routes/expenses.ts new file mode 100644 index 0000000..b21d906 --- /dev/null +++ b/backend/src/routes/expenses.ts @@ -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(); +}); diff --git a/backend/src/routes/guestCodes.ts b/backend/src/routes/guestCodes.ts new file mode 100644 index 0000000..39059f2 --- /dev/null +++ b/backend/src/routes/guestCodes.ts @@ -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 = {}; + 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(); +}); diff --git a/backend/src/routes/handoverProtocols.ts b/backend/src/routes/handoverProtocols.ts new file mode 100644 index 0000000..0bf47cd --- /dev/null +++ b/backend/src/routes/handoverProtocols.ts @@ -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 = {}; + 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 }); +}); diff --git a/backend/src/routes/inventory.ts b/backend/src/routes/inventory.ts new file mode 100644 index 0000000..16018c3 --- /dev/null +++ b/backend/src/routes/inventory.ts @@ -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' }); + } + }, +); diff --git a/backend/src/routes/storage.ts b/backend/src/routes/storage.ts new file mode 100644 index 0000000..9804f3a --- /dev/null +++ b/backend/src/routes/storage.ts @@ -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 = {}; + 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' }); + } + }, +);