Initial Kaffeekasse SaaS restart

This commit is contained in:
2026-06-15 17:13:38 +02:00
commit b08eb93547
54 changed files with 4617 additions and 0 deletions
+189
View File
@@ -0,0 +1,189 @@
<?php
namespace App\Controllers;
use App\Core\Auth;
use App\Core\Csrf;
use App\Core\Database;
use App\Core\Response;
use App\Core\Session;
use App\Core\View;
use App\Services\AuditService;
use App\Services\SetupService;
use App\Services\TenantRegistrationService;
use RuntimeException;
final class PlatformController
{
public function __construct(
private readonly string $rootPath,
private readonly array $config,
private readonly Database $database,
private readonly View $view,
private readonly Session $session,
private readonly Csrf $csrf
) {
}
public function home(): void
{
$setup = new SetupService($this->rootPath);
$stats = null;
if ($setup->isInstalled() && $this->database->isConfigured()) {
try {
$pdo = $this->database->pdo();
$stats = [
'tenants' => (int) $pdo->query('SELECT COUNT(*) FROM tenants')->fetchColumn(),
'members' => (int) $pdo->query('SELECT COUNT(*) FROM members')->fetchColumn(),
'events' => (int) $pdo->query('SELECT COUNT(*) FROM consumption_events')->fetchColumn(),
];
} catch (\Throwable) {
$stats = null;
}
}
$this->view->render('home/index', [
'title' => 'Kaffeekasse fuer Teams, Vereine und Coworking',
'setupComplete' => $setup->isInstalled(),
'stats' => $stats,
]);
}
public function installForm(): void
{
$setup = new SetupService($this->rootPath);
if ($setup->isInstalled()) {
Response::redirect('/admin/login');
}
$this->view->render('install/index', [
'title' => 'Installation',
]);
}
public function installSubmit(): void
{
$this->assertCsrf();
$setup = new SetupService($this->rootPath);
try {
$setup->install($_POST);
$this->session->flash('success', 'Die Installation wurde abgeschlossen. Jetzt kannst du dich als Plattform-Admin anmelden.');
Response::redirect('/admin/login');
} catch (\Throwable $throwable) {
remember_old_input($_POST);
$this->session->flash('error', $throwable->getMessage());
Response::redirect('/install');
}
}
public function registerForm(): void
{
if (!(new SetupService($this->rootPath))->isInstalled()) {
Response::redirect('/install');
}
$this->view->render('home/register', [
'title' => 'Mandant registrieren',
]);
}
public function registerSubmit(): void
{
$this->assertCsrf();
try {
$pdo = $this->database->pdo();
$audit = new AuditService($pdo);
$service = new TenantRegistrationService($pdo, $audit);
$slug = $service->register($_POST);
$this->session->flash('success', 'Dein Mandant wurde angelegt. Du kannst dich jetzt anmelden.');
Response::redirect('/t/' . rawurlencode($slug) . '/login');
} catch (\Throwable $throwable) {
remember_old_input($_POST);
$this->session->flash('error', $throwable->getMessage());
Response::redirect('/register');
}
}
public function adminLoginForm(): void
{
$this->view->render('admin/login', [
'title' => 'Plattform-Login',
]);
}
public function adminLoginSubmit(): void
{
$this->assertCsrf();
try {
$pdo = $this->database->pdo();
$auth = new Auth($pdo, $this->session);
$audit = new AuditService($pdo);
$success = $auth->attemptPlatformLogin((string) ($_POST['email'] ?? ''), (string) ($_POST['password'] ?? ''));
$audit->log(null, null, 'platform.login', 'user', null, $success ? 'success' : 'failure', [
'email' => mb_strtolower(trim((string) ($_POST['email'] ?? ''))),
]);
if (!$success) {
throw new RuntimeException('Die Zugangsdaten konnten nicht verifiziert werden.');
}
$this->session->flash('success', 'Willkommen im Plattformbereich.');
Response::redirect('/admin');
} catch (\Throwable $throwable) {
remember_old_input($_POST);
$this->session->flash('error', $throwable->getMessage());
Response::redirect('/admin/login');
}
}
public function adminDashboard(): void
{
$pdo = $this->database->pdo();
$auth = new Auth($pdo, $this->session);
if (!$auth->checkPlatformAdmin()) {
$this->session->flash('error', 'Bitte zuerst als Plattform-Admin anmelden.');
Response::redirect('/admin/login');
}
$tenants = $pdo->query(
'SELECT t.*,
COUNT(DISTINCT m.id) AS member_count,
COUNT(DISTINCT p.id) AS product_count
FROM tenants t
LEFT JOIN members m ON m.tenant_id = t.id
LEFT JOIN products p ON p.tenant_id = t.id
GROUP BY t.id
ORDER BY t.created_at DESC'
)->fetchAll();
$this->view->render('admin/index', [
'title' => 'Plattform-Dashboard',
'tenants' => $tenants,
]);
}
public function adminLogout(): void
{
$this->assertCsrf();
$pdo = $this->database->pdo();
$auth = new Auth($pdo, $this->session);
$auth->logout();
$this->session->flash('success', 'Du wurdest aus dem Plattformbereich abgemeldet.');
Response::redirect('/admin/login');
}
private function assertCsrf(): void
{
if (!request_origin_matches_host() || !$this->csrf->validate($_POST['_token'] ?? null)) {
throw new RuntimeException('Die Anfrage konnte nicht bestaetigt werden. Bitte erneut versuchen.');
}
}
}
+636
View File
@@ -0,0 +1,636 @@
<?php
namespace App\Controllers;
use App\Core\Auth;
use App\Core\Csrf;
use App\Core\Database;
use App\Core\Response;
use App\Core\Session;
use App\Core\TenantContext;
use App\Core\View;
use App\Services\AuditService;
use App\Services\LedgerService;
use App\Services\RfidService;
use RuntimeException;
final class TenantController
{
public function __construct(
private readonly Database $database,
private readonly View $view,
private readonly Session $session,
private readonly Csrf $csrf
) {
}
public function loginForm(string $tenantSlug): void
{
$tenant = $this->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.');
}
}
}