tenant($tenantSlug); $this->view->render('tenant/login', [ 'title' => 'Tenant-Login', 'tenant' => $tenant, ]); } public function loginSubmit(string $tenantSlug): void { $this->assertCsrf(); $tenant = $this->tenant($tenantSlug); $pdo = $this->database->pdo(); $auth = new Auth($pdo, $this->session); $audit = new AuditService($pdo); $success = $auth->attemptTenantLogin($tenantSlug, (string) ($_POST['email'] ?? ''), (string) ($_POST['password'] ?? '')); $audit->log((int) $tenant['id'], null, 'tenant.login', 'user', null, $success ? 'success' : 'failure', [ 'email' => mb_strtolower(trim((string) ($_POST['email'] ?? ''))), ]); if (!$success) { remember_old_input($_POST); $this->session->flash('error', 'Die Zugangsdaten konnten nicht verifiziert werden.'); Response::redirect(tenant_url($tenantSlug, 'login')); } $this->session->flash('success', 'Willkommen zur digitalen Kaffeeliste.'); Response::redirect(tenant_url($tenantSlug)); } 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(); $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 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 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']]); $this->view->render('tenant/dashboard', [ 'title' => 'Dashboard', 'tenant' => $tenant, 'authState' => $authState, 'summary' => $summary, 'latestEvents' => $eventsStatement->fetchAll(), 'memberBalances' => $balancesStatement->fetchAll(), ]); } 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.'); } $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.'); } $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 !== '' ? $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(); if ($_SERVER['REQUEST_METHOD'] === 'POST') { $this->assertCsrf(); try { $service = new LedgerService($pdo, new AuditService($pdo)); $service->createConsumption( (int) $tenant['id'], (int) ($_POST['member_id'] ?? 0), (int) ($_POST['product_id'] ?? 0), (string) ($_POST['source_code'] ?? 'digital_self'), 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 = $this->membersForTenant((int) $tenant['id']); $products = $this->productsForTenant((int) $tenant['id']); $bookingsStatement = $pdo->prepare( 'SELECT c.*, m.display_name AS member_name, p.name AS product_name, s.name AS source_name 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 WHERE c.tenant_id = :tenant_id ORDER BY c.recorded_at DESC LIMIT 30' ); $bookingsStatement->execute(['tenant_id' => $tenant['id']]); $this->view->render('tenant/bookings', [ 'title' => 'Digitale Buchungen', 'tenant' => $tenant, 'authState' => $authState, 'members' => $members, 'products' => $products, 'bookings' => $bookingsStatement->fetchAll(), ]); } 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)); $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 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 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(), ]); } 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.'); } } 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 FROM paper_sheets ps LEFT JOIN paper_sheet_lines psl ON psl.paper_sheet_id = ps.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(), ]); } 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(), ]); } public function rfidIngest(): void { $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)); } return [$tenant, $auth->user()]; } 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 assertCsrf(): void { if (!request_origin_matches_host() || !$this->csrf->validate($_POST['_token'] ?? null)) { throw new RuntimeException('Die Anfrage konnte nicht bestaetigt werden.'); } } }