tenant($tenantSlug); $this->view->render('tenant/login', [ 'title' => 'Tenant-Login', 'tenant' => $tenant, ]); } public function loginSubmit(string $tenantSlug): void { $this->assertCsrf(); try { $tenant = $this->tenant($tenantSlug); $pdo = $this->database->pdo(); $auth = new Auth($pdo, $this->session); $audit = new AuditService($pdo); $email = mb_strtolower(trim((string) ($_POST['email'] ?? ''))); $rateLimiter = $this->rateLimiter($pdo); $scopes = $this->buildRateLimitScopes($tenantSlug, $email); $this->ensureRateLimitAllowed($rateLimiter, $scopes); $success = $auth->attemptTenantLogin($tenantSlug, (string) ($_POST['email'] ?? ''), (string) ($_POST['password'] ?? '')); $audit->log((int) $tenant['id'], null, 'tenant.login', 'user', null, $success ? 'success' : 'failure', [ 'email' => $email, ]); if (!$success) { $this->hitRateLimitScopes($rateLimiter, $scopes); throw new RuntimeException('Die Zugangsdaten konnten nicht verifiziert werden.'); } $authState = $auth->user() ?? []; if (!$this->isTenantManager($authState) && !$this->selfServiceAllowed((int) $tenant['id'])) { $auth->logout(); throw new RuntimeException('Die digitale Selbstbuchung ist fuer diesen Mandanten derzeit deaktiviert.'); } $this->clearRateLimitScopes($rateLimiter, $scopes); $this->session->flash('success', 'Willkommen zur digitalen Kaffeeliste.'); Response::redirect(tenant_url($tenantSlug)); } catch (\Throwable $throwable) { remember_old_input($_POST); $this->session->flash('error', $throwable->getMessage()); Response::redirect(tenant_url($tenantSlug, 'login')); } } public function logout(string $tenantSlug): void { $this->assertCsrf(); $pdo = $this->database->pdo(); $auth = new Auth($pdo, $this->session); $auth->logout(); $this->session->flash('success', 'Du wurdest abgemeldet.'); Response::redirect(tenant_url($tenantSlug, 'login')); } public function dashboard(string $tenantSlug): void { [$tenant, $authState] = $this->tenantAccess($tenantSlug); $pdo = $this->database->pdo(); $isManager = $this->isTenantManager($authState); $currentMember = $this->memberRecordForAuthState((int) $tenant['id'], $authState); if ($isManager) { $summaryStatement = $pdo->prepare( 'SELECT COUNT(DISTINCT members.id) AS member_count, COUNT(DISTINCT products.id) AS product_count, COUNT(DISTINCT paper_sheets.id) AS paper_sheet_count, COALESCE(SUM(CASE WHEN ledger_lines.account_code = "member_balance" THEN ledger_lines.amount_cents ELSE 0 END), 0) AS open_balance_cents FROM tenants LEFT JOIN members ON members.tenant_id = tenants.id AND members.status = "active" LEFT JOIN products ON products.tenant_id = tenants.id AND products.is_active = 1 LEFT JOIN paper_sheets ON paper_sheets.tenant_id = tenants.id LEFT JOIN ledger_lines ON ledger_lines.tenant_id = tenants.id WHERE tenants.id = :tenant_id GROUP BY tenants.id' ); $summaryStatement->execute(['tenant_id' => $tenant['id']]); $summary = $summaryStatement->fetch() ?: [ 'member_count' => 0, 'product_count' => 0, 'paper_sheet_count' => 0, 'open_balance_cents' => 0, ]; $eventsStatement = $pdo->prepare( 'SELECT c.*, m.display_name AS member_name, p.name AS product_name, s.name AS source_name, CASE WHEN reversal.id IS NULL THEN 0 ELSE 1 END AS is_reversed FROM consumption_events c INNER JOIN members m ON m.id = c.member_id INNER JOIN products p ON p.id = c.product_id INNER JOIN capture_sources s ON s.id = c.source_id LEFT JOIN ledger_entries le ON le.tenant_id = c.tenant_id AND le.reference_type = "consumption_event" AND le.reference_id = c.id AND le.type = "consumption" LEFT JOIN ledger_entries reversal ON reversal.tenant_id = le.tenant_id AND reversal.reversal_of_entry_id = le.id WHERE c.tenant_id = :tenant_id ORDER BY c.recorded_at DESC LIMIT 8' ); $eventsStatement->execute(['tenant_id' => $tenant['id']]); $balancesStatement = $pdo->prepare( 'SELECT m.id, m.display_name, COALESCE(SUM(ll.amount_cents), 0) AS balance_cents FROM members m LEFT JOIN ledger_lines ll ON ll.member_id = m.id AND ll.tenant_id = m.tenant_id AND ll.account_code = "member_balance" WHERE m.tenant_id = :tenant_id GROUP BY m.id ORDER BY balance_cents DESC, m.display_name ASC LIMIT 8' ); $balancesStatement->execute(['tenant_id' => $tenant['id']]); } else { if ($currentMember === null) { throw new RuntimeException('Fuer diesen Benutzer ist kein aktives Mitglied im Mandanten hinterlegt.'); } $summaryStatement = $pdo->prepare( 'SELECT 1 AS member_count, (SELECT COUNT(*) FROM products WHERE tenant_id = :tenant_id_products AND is_active = 1) AS product_count, 0 AS paper_sheet_count, COALESCE(SUM(CASE WHEN ll.account_code = "member_balance" THEN ll.amount_cents ELSE 0 END), 0) AS open_balance_cents FROM members m LEFT JOIN ledger_lines ll ON ll.member_id = m.id AND ll.tenant_id = m.tenant_id WHERE m.tenant_id = :tenant_id AND m.id = :member_id GROUP BY m.id' ); $summaryStatement->execute([ 'tenant_id_products' => $tenant['id'], 'tenant_id' => $tenant['id'], 'member_id' => $currentMember['id'], ]); $summary = $summaryStatement->fetch() ?: [ 'member_count' => 1, 'product_count' => 0, 'paper_sheet_count' => 0, 'open_balance_cents' => 0, ]; $eventsStatement = $pdo->prepare( 'SELECT c.*, m.display_name AS member_name, p.name AS product_name, s.name AS source_name, CASE WHEN reversal.id IS NULL THEN 0 ELSE 1 END AS is_reversed FROM consumption_events c INNER JOIN members m ON m.id = c.member_id INNER JOIN products p ON p.id = c.product_id INNER JOIN capture_sources s ON s.id = c.source_id LEFT JOIN ledger_entries le ON le.tenant_id = c.tenant_id AND le.reference_type = "consumption_event" AND le.reference_id = c.id AND le.type = "consumption" LEFT JOIN ledger_entries reversal ON reversal.tenant_id = le.tenant_id AND reversal.reversal_of_entry_id = le.id WHERE c.tenant_id = :tenant_id AND c.member_id = :member_id ORDER BY c.recorded_at DESC LIMIT 8' ); $eventsStatement->execute([ 'tenant_id' => $tenant['id'], 'member_id' => $currentMember['id'], ]); $balancesStatement = $pdo->prepare( 'SELECT m.id, m.display_name, COALESCE(SUM(ll.amount_cents), 0) AS balance_cents FROM members m LEFT JOIN ledger_lines ll ON ll.member_id = m.id AND ll.tenant_id = m.tenant_id AND ll.account_code = "member_balance" WHERE m.tenant_id = :tenant_id AND m.id = :member_id GROUP BY m.id' ); $balancesStatement->execute([ 'tenant_id' => $tenant['id'], 'member_id' => $currentMember['id'], ]); } $this->view->render('tenant/dashboard', [ 'title' => 'Dashboard', 'tenant' => $tenant, 'authState' => $authState, 'summary' => $summary, 'latestEvents' => $eventsStatement->fetchAll(), 'memberBalances' => $balancesStatement->fetchAll(), 'isManager' => $isManager, 'currentMember' => $currentMember, ]); } public function members(string $tenantSlug): void { [$tenant, $authState] = $this->tenantAccess($tenantSlug, ['owner', 'admin']); $pdo = $this->database->pdo(); if ($_SERVER['REQUEST_METHOD'] === 'POST') { $this->assertCsrf(); try { $displayName = trim((string) ($_POST['display_name'] ?? '')); $email = trim((string) ($_POST['email'] ?? '')); $accessPin = trim((string) ($_POST['access_pin'] ?? '')); $loginPassword = (string) ($_POST['login_password'] ?? ''); if ($displayName === '') { throw new RuntimeException('Bitte einen Anzeigenamen angeben.'); } if ($accessPin !== '' && mb_strlen($accessPin) < 4) { throw new RuntimeException('Die PIN muss mindestens 4 Zeichen haben.'); } $userId = null; if ($email !== '') { if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { throw new RuntimeException('Die E-Mail-Adresse ist ungueltig.'); } if ($loginPassword === '' || mb_strlen($loginPassword) < 12) { throw new RuntimeException('Fuer Login-faehige Mitglieder ist ein Passwort mit mindestens 12 Zeichen noetig.'); } $existingUserStatement = $pdo->prepare('SELECT id FROM users WHERE email = :email LIMIT 1'); $existingUserStatement->execute(['email' => mb_strtolower($email)]); if ($existingUserStatement->fetchColumn()) { throw new RuntimeException('Die E-Mail-Adresse ist bereits im System vorhanden.'); } $userInsert = $pdo->prepare( 'INSERT INTO users (full_name, email, password_hash, platform_role) VALUES (:full_name, :email, :password_hash, "user")' ); $userInsert->execute([ 'full_name' => $displayName, 'email' => mb_strtolower($email), 'password_hash' => secure_password_hash($loginPassword), ]); $userId = (int) $pdo->lastInsertId(); $membershipInsert = $pdo->prepare( 'INSERT INTO tenant_memberships (tenant_id, user_id, role, status) VALUES (:tenant_id, :user_id, "member", "active")' ); $membershipInsert->execute([ 'tenant_id' => $tenant['id'], 'user_id' => $userId, ]); } $memberInsert = $pdo->prepare( 'INSERT INTO members (tenant_id, user_id, display_name, email, access_pin, status) VALUES (:tenant_id, :user_id, :display_name, :email, :access_pin, "active")' ); $memberInsert->execute([ 'tenant_id' => $tenant['id'], 'user_id' => $userId, 'display_name' => $displayName, 'email' => $email !== '' ? mb_strtolower($email) : null, 'access_pin' => $accessPin !== '' ? secure_password_hash($accessPin) : null, ]); (new AuditService($pdo))->log((int) $tenant['id'], (int) $authState['user_id'], 'member.created', 'member', (int) $pdo->lastInsertId(), 'success'); $this->session->flash('success', 'Das Mitglied wurde gespeichert.'); } catch (\Throwable $throwable) { remember_old_input($_POST); $this->session->flash('error', $throwable->getMessage()); } Response::redirect(tenant_url($tenantSlug, 'members')); } $membersStatement = $pdo->prepare( 'SELECT m.*, u.email AS login_email FROM members m LEFT JOIN users u ON u.id = m.user_id WHERE m.tenant_id = :tenant_id ORDER BY m.display_name ASC' ); $membersStatement->execute(['tenant_id' => $tenant['id']]); $this->view->render('tenant/members', [ 'title' => 'Mitglieder', 'tenant' => $tenant, 'authState' => $authState, 'members' => $membersStatement->fetchAll(), ]); } public function products(string $tenantSlug): void { [$tenant, $authState] = $this->tenantAccess($tenantSlug, ['owner', 'admin']); $pdo = $this->database->pdo(); if ($_SERVER['REQUEST_METHOD'] === 'POST') { $this->assertCsrf(); try { $name = trim((string) ($_POST['name'] ?? '')); $priceEur = (float) ($_POST['price_eur'] ?? 0); if ($name === '' || $priceEur <= 0) { throw new RuntimeException('Bitte Produktname und Preis angeben.'); } $productInsert = $pdo->prepare( 'INSERT INTO products (tenant_id, name, sku, is_active) VALUES (:tenant_id, :name, :sku, 1)' ); $productInsert->execute([ 'tenant_id' => $tenant['id'], 'name' => $name, 'sku' => strtolower(str_replace(' ', '-', $name)), ]); $productId = (int) $pdo->lastInsertId(); $priceInsert = $pdo->prepare( 'INSERT INTO product_prices (tenant_id, product_id, price_cents, valid_from) VALUES (:tenant_id, :product_id, :price_cents, :valid_from)' ); $priceInsert->execute([ 'tenant_id' => $tenant['id'], 'product_id' => $productId, 'price_cents' => (int) round($priceEur * 100), 'valid_from' => gmdate('Y-m-d H:i:s'), ]); (new AuditService($pdo))->log((int) $tenant['id'], (int) $authState['user_id'], 'product.created', 'product', $productId, 'success'); $this->session->flash('success', 'Das Produkt wurde angelegt.'); } catch (\Throwable $throwable) { remember_old_input($_POST); $this->session->flash('error', $throwable->getMessage()); } Response::redirect(tenant_url($tenantSlug, 'products')); } $productsStatement = $pdo->prepare( 'SELECT p.*, pp.price_cents FROM products p LEFT JOIN product_prices pp ON pp.product_id = p.id AND pp.tenant_id = p.tenant_id AND pp.valid_until IS NULL WHERE p.tenant_id = :tenant_id ORDER BY p.name ASC' ); $productsStatement->execute(['tenant_id' => $tenant['id']]); $this->view->render('tenant/products', [ 'title' => 'Produkte', 'tenant' => $tenant, 'authState' => $authState, 'products' => $productsStatement->fetchAll(), ]); } public function bookings(string $tenantSlug): void { [$tenant, $authState] = $this->tenantAccess($tenantSlug); $pdo = $this->database->pdo(); $isManager = $this->isTenantManager($authState); $currentMember = $this->memberRecordForAuthState((int) $tenant['id'], $authState); if ($_SERVER['REQUEST_METHOD'] === 'POST') { $this->assertCsrf(); try { $service = new LedgerService($pdo, new AuditService($pdo)); $action = (string) ($_POST['action'] ?? 'create_booking'); if ($action === 'reverse_booking') { if (!$isManager) { throw new RuntimeException('Nur Admins duerfen Buchungen stornieren.'); } $service->reverseLedgerEntry( (int) $tenant['id'], (int) ($_POST['ledger_entry_id'] ?? 0), (int) $authState['user_id'], trim((string) ($_POST['reason'] ?? '')) ?: null ); $this->session->flash('success', 'Die Buchung wurde storniert.'); } else { $memberId = $this->selectedMemberIdForBooking((int) $tenant['id'], $authState, (int) ($_POST['member_id'] ?? 0), $currentMember); $sourceCode = $isManager ? (string) ($_POST['source_code'] ?? 'digital_self') : 'digital_self'; $service->createConsumption( (int) $tenant['id'], $memberId, (int) ($_POST['product_id'] ?? 0), $sourceCode, normalize_datetime_input((string) ($_POST['effective_at'] ?? '')), (int) $authState['user_id'], (float) ($_POST['quantity'] ?? 1), trim((string) ($_POST['notes'] ?? '')) ); $this->session->flash('success', 'Die Buchung wurde gespeichert.'); } } catch (\Throwable $throwable) { remember_old_input($_POST); $this->session->flash('error', $throwable->getMessage()); } Response::redirect(tenant_url($tenantSlug, 'bookings')); } $members = $isManager ? $this->membersForTenant((int) $tenant['id']) : ($currentMember !== null ? [['id' => $currentMember['id'], 'display_name' => $currentMember['display_name']]] : []); $products = $this->productsForTenant((int) $tenant['id']); $sql = 'SELECT c.*, m.display_name AS member_name, p.name AS product_name, s.name AS source_name, le.id AS ledger_entry_id, CASE WHEN reversal.id IS NULL THEN 0 ELSE 1 END AS is_reversed FROM consumption_events c INNER JOIN members m ON m.id = c.member_id INNER JOIN products p ON p.id = c.product_id INNER JOIN capture_sources s ON s.id = c.source_id LEFT JOIN ledger_entries le ON le.tenant_id = c.tenant_id AND le.reference_type = "consumption_event" AND le.reference_id = c.id AND le.type = "consumption" LEFT JOIN ledger_entries reversal ON reversal.tenant_id = le.tenant_id AND reversal.reversal_of_entry_id = le.id WHERE c.tenant_id = :tenant_id'; $params = ['tenant_id' => $tenant['id']]; if (!$isManager) { if ($currentMember === null) { throw new RuntimeException('Fuer diesen Benutzer ist kein aktives Mitglied im Mandanten hinterlegt.'); } $sql .= ' AND c.member_id = :member_id'; $params['member_id'] = $currentMember['id']; } $sql .= ' ORDER BY c.recorded_at DESC LIMIT 30'; $bookingsStatement = $pdo->prepare($sql); $bookingsStatement->execute($params); $this->view->render('tenant/bookings', [ 'title' => 'Digitale Buchungen', 'tenant' => $tenant, 'authState' => $authState, 'members' => $members, 'products' => $products, 'bookings' => $bookingsStatement->fetchAll(), 'isManager' => $isManager, 'currentMember' => $currentMember, ]); } public function exportBalances(string $tenantSlug): void { [$tenant, $authState] = $this->tenantAccess($tenantSlug, ['owner', 'admin']); (new AuditService($this->database->pdo()))->log( (int) $tenant['id'], (int) $authState['user_id'], 'export.member_balances', 'tenant', (int) $tenant['id'], 'success' ); (new ExportService($this->database->pdo()))->streamMemberBalancesCsv( (int) $tenant['id'], $tenant['slug'] . '-salden-' . gmdate('Ymd-His') . '.csv' ); } public function exportBookings(string $tenantSlug): void { [$tenant, $authState] = $this->tenantAccess($tenantSlug); $isManager = $this->isTenantManager($authState); $currentMember = $this->memberRecordForAuthState((int) $tenant['id'], $authState); $memberId = null; if ($isManager) { $requestedMemberId = (int) ($_GET['member_id'] ?? 0); if ($requestedMemberId > 0) { $memberId = $this->assertMemberIdBelongsToTenant((int) $tenant['id'], $requestedMemberId); } } else { if ($currentMember === null) { throw new RuntimeException('Fuer diesen Benutzer ist kein aktives Mitglied im Mandanten hinterlegt.'); } $memberId = (int) $currentMember['id']; } (new AuditService($this->database->pdo()))->log( (int) $tenant['id'], (int) $authState['user_id'], 'export.bookings', 'tenant', (int) $tenant['id'], 'success', [ 'member_id' => $memberId, 'from' => trim((string) ($_GET['from'] ?? '')) ?: null, 'until' => trim((string) ($_GET['until'] ?? '')) ?: null, ] ); $filename = $tenant['slug'] . '-buchungen-' . gmdate('Ymd-His') . '.csv'; if ($memberId !== null) { $filename = $tenant['slug'] . '-mitglied-' . $memberId . '-buchungen-' . gmdate('Ymd-His') . '.csv'; } (new ExportService($this->database->pdo()))->streamBookingsCsv( (int) $tenant['id'], $memberId, trim((string) ($_GET['from'] ?? '')) ?: null, trim((string) ($_GET['until'] ?? '')) ?: null, $filename ); } public function payments(string $tenantSlug): void { [$tenant, $authState] = $this->tenantAccess($tenantSlug, ['owner', 'admin']); $pdo = $this->database->pdo(); if ($_SERVER['REQUEST_METHOD'] === 'POST') { $this->assertCsrf(); try { $service = new LedgerService($pdo, new AuditService($pdo)); $action = (string) ($_POST['action'] ?? 'create_payment'); if ($action === 'reverse_payment') { $service->reverseLedgerEntry( (int) $tenant['id'], (int) ($_POST['ledger_entry_id'] ?? 0), (int) $authState['user_id'], trim((string) ($_POST['reason'] ?? '')) ?: null ); $this->session->flash('success', 'Die Einzahlung wurde storniert.'); } else { $service->createPayment( (int) $tenant['id'], (int) ($_POST['member_id'] ?? 0), (int) round(((float) ($_POST['amount_eur'] ?? 0)) * 100), normalize_datetime_input((string) ($_POST['occurred_at'] ?? '')), (int) $authState['user_id'], trim((string) ($_POST['notes'] ?? '')) ); $this->session->flash('success', 'Die Einzahlung wurde gebucht.'); } } catch (\Throwable $throwable) { remember_old_input($_POST); $this->session->flash('error', $throwable->getMessage()); } Response::redirect(tenant_url($tenantSlug, 'payments')); } $members = $this->membersForTenant((int) $tenant['id']); $paymentsStatement = $pdo->prepare( 'SELECT le.*, m.display_name AS member_name, ABS(COALESCE(SUM(CASE WHEN ll.account_code = "member_balance" THEN ll.amount_cents ELSE 0 END), 0)) AS amount_cents, CASE WHEN reversal.id IS NULL THEN 0 ELSE 1 END AS is_reversed FROM ledger_entries le LEFT JOIN members m ON m.id = le.member_id LEFT JOIN ledger_lines ll ON ll.ledger_entry_id = le.id LEFT JOIN ledger_entries reversal ON reversal.tenant_id = le.tenant_id AND reversal.reversal_of_entry_id = le.id WHERE le.tenant_id = :tenant_id AND le.type = "payment" GROUP BY le.id ORDER BY le.occurred_at DESC LIMIT 30' ); $paymentsStatement->execute(['tenant_id' => $tenant['id']]); $this->view->render('tenant/payments', [ 'title' => 'Einzahlungen', 'tenant' => $tenant, 'authState' => $authState, 'members' => $members, 'payments' => $paymentsStatement->fetchAll(), 'isManager' => true, ]); } public function paperSheets(string $tenantSlug): void { [$tenant, $authState] = $this->tenantAccess($tenantSlug, ['owner', 'admin']); $pdo = $this->database->pdo(); $service = new LedgerService($pdo, new AuditService($pdo)); if ($_SERVER['REQUEST_METHOD'] === 'POST') { $this->assertCsrf(); try { $action = (string) ($_POST['action'] ?? ''); if ($action === 'create_sheet') { $service->createPaperSheet( (int) $tenant['id'], trim((string) ($_POST['title'] ?? '')), trim((string) ($_POST['period_label'] ?? '')) ?: null, trim((string) ($_POST['period_start'] ?? '')) ?: null, trim((string) ($_POST['period_end'] ?? '')) ?: null, trim((string) ($_POST['notes'] ?? '')) ?: null, (int) $authState['user_id'] ); $this->session->flash('success', 'Die Papierliste wurde angelegt.'); } elseif ($action === 'add_line') { $service->addPaperSheetLine( (int) $tenant['id'], (int) ($_POST['paper_sheet_id'] ?? 0), (int) ($_POST['member_id'] ?? 0), (int) ($_POST['product_id'] ?? 0), (float) ($_POST['quantity'] ?? 1), trim((string) ($_POST['effective_at'] ?? '')) !== '' ? normalize_datetime_input((string) $_POST['effective_at']) : null, trim((string) ($_POST['note'] ?? '')) ?: null, (int) $authState['user_id'] ); $this->session->flash('success', 'Die Papierlisten-Position wurde gespeichert.'); } elseif ($action === 'post_sheet') { $service->postPaperSheet((int) $tenant['id'], (int) ($_POST['paper_sheet_id'] ?? 0), (int) $authState['user_id']); $this->session->flash('success', 'Die Papierliste wurde ins Ledger uebernommen.'); } elseif ($action === 'reverse_sheet') { $service->reversePaperSheet( (int) $tenant['id'], (int) ($_POST['paper_sheet_id'] ?? 0), (int) $authState['user_id'], trim((string) ($_POST['reason'] ?? '')) ?: null ); $this->session->flash('success', 'Die verbuchte Papierliste wurde storniert.'); } } catch (\Throwable $throwable) { remember_old_input($_POST); $this->session->flash('error', $throwable->getMessage()); } Response::redirect(tenant_url($tenantSlug, 'paper-sheets')); } $members = $this->membersForTenant((int) $tenant['id']); $products = $this->productsForTenant((int) $tenant['id']); $sheetsStatement = $pdo->prepare( 'SELECT ps.*, COUNT(psl.id) AS line_count, COUNT(DISTINCT le.id) AS posted_entry_count, COUNT(DISTINCT reversal.id) AS reversed_entry_count FROM paper_sheets ps LEFT JOIN paper_sheet_lines psl ON psl.paper_sheet_id = ps.id LEFT JOIN consumption_events ce ON ce.paper_sheet_line_id = psl.id LEFT JOIN ledger_entries le ON le.reference_type = "consumption_event" AND le.reference_id = ce.id AND le.tenant_id = ps.tenant_id AND le.type = "consumption" LEFT JOIN ledger_entries reversal ON reversal.reversal_of_entry_id = le.id AND reversal.tenant_id = le.tenant_id WHERE ps.tenant_id = :tenant_id GROUP BY ps.id ORDER BY ps.created_at DESC' ); $sheetsStatement->execute(['tenant_id' => $tenant['id']]); $sheets = $sheetsStatement->fetchAll(); $linesStatement = $pdo->prepare( 'SELECT psl.*, m.display_name AS member_name, p.name AS product_name FROM paper_sheet_lines psl INNER JOIN members m ON m.id = psl.member_id INNER JOIN products p ON p.id = psl.product_id WHERE psl.tenant_id = :tenant_id ORDER BY psl.created_at DESC' ); $linesStatement->execute(['tenant_id' => $tenant['id']]); $this->view->render('tenant/paper-sheets', [ 'title' => 'Papierlisten', 'tenant' => $tenant, 'authState' => $authState, 'members' => $members, 'products' => $products, 'sheets' => $sheets, 'sheetLines' => $linesStatement->fetchAll(), 'isManager' => true, ]); } public function rfid(string $tenantSlug): void { [$tenant, $authState] = $this->tenantAccess($tenantSlug, ['owner', 'admin']); $pdo = $this->database->pdo(); $service = new RfidService($pdo, new AuditService($pdo)); if ($_SERVER['REQUEST_METHOD'] === 'POST') { $this->assertCsrf(); try { $action = (string) ($_POST['action'] ?? ''); if ($action === 'create_device') { $token = $service->createDevice( (int) $tenant['id'], trim((string) ($_POST['name'] ?? '')), trim((string) ($_POST['location_label'] ?? '')) ?: null, (int) $authState['user_id'] ); $this->session->flash('success', 'Das RFID-Geraet wurde vorbereitet. Token: ' . $token); } elseif ($action === 'assign_tag') { $service->assignTag( (int) $tenant['id'], (int) ($_POST['member_id'] ?? 0), trim((string) ($_POST['uid'] ?? '')), trim((string) ($_POST['label'] ?? '')) ?: null, (int) $authState['user_id'] ); $this->session->flash('success', 'Die RFID-Karte wurde zugewiesen.'); } } catch (\Throwable $throwable) { remember_old_input($_POST); $this->session->flash('error', $throwable->getMessage()); } Response::redirect(tenant_url($tenantSlug, 'rfid')); } $members = $this->membersForTenant((int) $tenant['id']); $devices = $pdo->prepare('SELECT * FROM rfid_devices WHERE tenant_id = :tenant_id ORDER BY created_at DESC'); $devices->execute(['tenant_id' => $tenant['id']]); $tags = $pdo->prepare( 'SELECT t.*, m.display_name AS member_name FROM rfid_tags t LEFT JOIN members m ON m.id = t.member_id WHERE t.tenant_id = :tenant_id ORDER BY t.created_at DESC' ); $tags->execute(['tenant_id' => $tenant['id']]); $events = $pdo->prepare( 'SELECT e.*, d.name AS device_name FROM rfid_events e LEFT JOIN rfid_devices d ON d.id = e.device_id WHERE e.tenant_id = :tenant_id ORDER BY e.received_at DESC LIMIT 20' ); $events->execute(['tenant_id' => $tenant['id']]); $this->view->render('tenant/rfid', [ 'title' => 'RFID-Roadmap', 'tenant' => $tenant, 'authState' => $authState, 'members' => $members, 'devices' => $devices->fetchAll(), 'tags' => $tags->fetchAll(), 'events' => $events->fetchAll(), 'isManager' => true, 'rfidIngestEnabled' => (bool) ($this->config['rfid_ingest_enabled'] ?? false), ]); } public function rfidIngest(): void { if (!($this->config['rfid_ingest_enabled'] ?? false)) { Response::notFound(); return; } $payload = json_decode(file_get_contents('php://input') ?: '[]', true); if (!is_array($payload)) { http_response_code(422); header('Content-Type: application/json'); echo json_encode(['error' => 'Invalid payload']); return; } try { $pdo = $this->database->pdo(); (new RfidService($pdo, new AuditService($pdo)))->ingest($payload); header('Content-Type: application/json'); echo json_encode(['status' => 'accepted']); } catch (\Throwable $throwable) { http_response_code(422); header('Content-Type: application/json'); echo json_encode(['error' => $throwable->getMessage()]); } } private function tenant(string $tenantSlug): array { $pdo = $this->database->pdo(); $tenant = (new TenantContext($pdo))->bySlug($tenantSlug); if (!$tenant || $tenant['status'] === 'suspended') { throw new RuntimeException('Der Mandant ist nicht verfuegbar.'); } return $tenant; } private function tenantAccess(string $tenantSlug, array $roles = []): array { $tenant = $this->tenant($tenantSlug); $pdo = $this->database->pdo(); $auth = new Auth($pdo, $this->session); if (!$auth->checkTenantAccess($tenantSlug)) { $this->session->flash('error', 'Bitte zuerst anmelden.'); Response::redirect(tenant_url($tenantSlug, 'login')); } if ($roles !== [] && !$auth->requireRole($roles)) { $this->session->flash('error', 'Dafuer fehlen die passenden Rechte.'); Response::redirect(tenant_url($tenantSlug)); } $authState = $auth->user(); if (!is_array($authState)) { $this->session->flash('error', 'Bitte zuerst anmelden.'); Response::redirect(tenant_url($tenantSlug, 'login')); } if (!$this->isTenantManager($authState) && !$this->selfServiceAllowed((int) $tenant['id'])) { $auth->logout(); $this->session->flash('error', 'Die digitale Selbstbuchung ist fuer diesen Mandanten derzeit deaktiviert.'); Response::redirect(tenant_url($tenantSlug, 'login')); } return [$tenant, $authState]; } private function membersForTenant(int $tenantId): array { $statement = $this->database->pdo()->prepare( 'SELECT id, display_name FROM members WHERE tenant_id = :tenant_id AND status = "active" ORDER BY display_name ASC' ); $statement->execute(['tenant_id' => $tenantId]); return $statement->fetchAll(); } private function productsForTenant(int $tenantId): array { $statement = $this->database->pdo()->prepare( 'SELECT p.id, p.name, pp.price_cents FROM products p LEFT JOIN product_prices pp ON pp.product_id = p.id AND pp.tenant_id = p.tenant_id AND pp.valid_until IS NULL WHERE p.tenant_id = :tenant_id AND p.is_active = 1 ORDER BY p.name ASC' ); $statement->execute(['tenant_id' => $tenantId]); return $statement->fetchAll(); } private function memberRecordForAuthState(int $tenantId, array $authState): ?array { $memberId = $authState['member_id'] ?? null; if (!is_int($memberId)) { return null; } $statement = $this->database->pdo()->prepare( 'SELECT id, display_name, email FROM members WHERE tenant_id = :tenant_id AND id = :id AND status = "active" LIMIT 1' ); $statement->execute([ 'tenant_id' => $tenantId, 'id' => $memberId, ]); $member = $statement->fetch(); return $member ?: null; } private function isTenantManager(array $authState): bool { $role = $authState['tenant_role'] ?? null; return is_string($role) && in_array($role, ['owner', 'admin'], true); } private function selectedMemberIdForBooking( int $tenantId, array $authState, int $postedMemberId, ?array $currentMember ): int { if ($this->isTenantManager($authState)) { if ($postedMemberId <= 0) { throw new RuntimeException('Bitte ein Mitglied fuer die Buchung auswaehlen.'); } return $this->assertMemberIdBelongsToTenant($tenantId, $postedMemberId); } if ($currentMember === null) { throw new RuntimeException('Fuer dieses Konto ist kein buchungsfaehiges Mitglied hinterlegt.'); } if ($postedMemberId > 0 && $postedMemberId !== (int) $currentMember['id']) { throw new RuntimeException('Du darfst nur fuer dein eigenes Mitglied buchen.'); } return (int) $currentMember['id']; } private function assertMemberIdBelongsToTenant(int $tenantId, int $memberId): int { $statement = $this->database->pdo()->prepare( 'SELECT id FROM members WHERE tenant_id = :tenant_id AND id = :id AND status = "active" LIMIT 1' ); $statement->execute([ 'tenant_id' => $tenantId, 'id' => $memberId, ]); if (!$statement->fetchColumn()) { throw new RuntimeException('Das angegebene Mitglied gehoert nicht zu diesem Mandanten.'); } return $memberId; } private function assertCsrf(): void { if (!request_origin_matches_host() || !$this->csrf->validate($_POST['_token'] ?? null)) { throw new RuntimeException('Die Anfrage konnte nicht bestaetigt werden.'); } } private function selfServiceAllowed(int $tenantId): bool { $statement = $this->database->pdo()->prepare( 'SELECT allow_self_service FROM tenant_settings WHERE tenant_id = :tenant_id LIMIT 1' ); $statement->execute(['tenant_id' => $tenantId]); $value = $statement->fetchColumn(); return $value === false ? true : (int) $value === 1; } private function rateLimiter(\PDO $pdo): RateLimiterService { return new RateLimiterService($pdo, $this->config['rate_limits'] ?? []); } private function buildRateLimitScopes(string $tenantSlug, string $email): array { $ip = trim((string) ($_SERVER['REMOTE_ADDR'] ?? 'unknown')); $scopes = ['tenant-login|ip|' . $tenantSlug . '|' . $ip]; if (trim($email) !== '') { $scopes[] = 'tenant-login|subject|' . $tenantSlug . '|' . $email; } return $scopes; } private function ensureRateLimitAllowed(RateLimiterService $rateLimiter, array $scopes): void { foreach ($scopes as $scope) { $state = $rateLimiter->inspect('login', $scope); if (!$state['allowed']) { throw new RuntimeException( 'Zu viele Login-Versuche. Bitte in etwa ' . (int) $state['retry_after_seconds'] . ' Sekunden erneut versuchen.' ); } } } private function hitRateLimitScopes(RateLimiterService $rateLimiter, array $scopes): void { foreach ($scopes as $scope) { $rateLimiter->hit('login', $scope); } } private function clearRateLimitScopes(RateLimiterService $rateLimiter, array $scopes): void { foreach ($scopes as $scope) { $rateLimiter->clear('login', $scope); } } }