commit b08eb93547e772a65db912c240d849d6cf540952 Author: Clemens Creutzburg Date: Mon Jun 15 17:13:38 2026 +0200 Initial Kaffeekasse SaaS restart diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..00e2d7f --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +APP_NAME="Kaffeekasse SaaS" +APP_ENV=production +APP_DEBUG=0 +APP_URL=http://localhost:8080 +APP_TIMEZONE=Europe/Berlin +APP_KEY= + +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_NAME=kaffeeliste +DB_USER=root +DB_PASS= + +MAIL_FROM=noreply@example.com +RFID_SHARED_SECRET=change-me diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa4d164 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +/vendor/ +/.env +/build/ +/storage/cache/* +/storage/logs/* +/storage/uploads/* +/storage/cache/.gitkeep +/storage/logs/.gitkeep +/storage/uploads/.gitkeep +/storage/installed.lock +/.idea/ +/.vscode/ +/.DS_Store diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..a0b59cb --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,55 @@ +# Deployment auf Netcup Webspace + +## Zielbild + +- Code unter einem Release-Pfad wie `/apps/kaffeekasse/current` +- Document Root in Plesk auf `/apps/kaffeekasse/current/public` +- `.env` ausserhalb des Webroots oder direkt im Projektverzeichnis ohne + Webzugriff +- Migrationen ueber `bin/migrate.php` +- zyklische Aufgaben ueber `bin/cron.php` + +## Erstinstallation + +1. Neue Domain oder Subdomain im Netcup WCP/Plesk anlegen. +2. Document Root auf das Verzeichnis `public/` des Releases zeigen lassen. +3. MySQL-Datenbank und separaten User anlegen. +4. Projekt per FTPES oder SFTP hochladen. +5. Installer unter `/install` aufrufen oder `.env` manuell setzen. +6. Alternativ oder zusaetzlich: + `/usr/local/php83/bin/php /apps/kaffeekasse/current/bin/migrate.php` +7. Plattform-Admin einloggen und ersten Mandanten registrieren. + +## Scheduled Tasks + +- Alle 5 Minuten: + `/usr/local/php83/bin/php /apps/kaffeekasse/current/bin/cron.php` +- Optional Healthcheck: + `/usr/local/php83/bin/php /apps/kaffeekasse/current/bin/healthcheck.php` + +## Release-Prozess + +1. `scripts/build-release.sh` erzeugt ein auslieferbares Paket. +2. Paket hochladen und entpacken. +3. `.env` und persistente `storage/`-Pfade sichern. +4. `bin/migrate.php` ausfuehren. +5. Healthcheck und Login pruefen. + +## Sicherheitshinweise + +- `public/` bleibt der einzige Webroot. +- `.env`, Dumps, Backups und `.git` duerfen nicht oeffentlich erreichbar sein. +- Fuer produktive Domains HTTPS erzwingen. +- Session-Cookies sind auf `HttpOnly`, `SameSite=Lax` und `Secure` ausgelegt. +- Tenant-Daten werden serverseitig ueber Session und Memberships zugeordnet. + +## Git-Remote vorbereiten + +Beispiel fuer spaeteres Pushen auf `git.ctb-it.de`: + +```bash +git remote add origin ssh://git@git.ctb-it.de/namespace/kaffeekasse-saas.git +git push -u origin main +``` + +Die genaue URL haengt von eurem Namespace und dem Zielsystem ab. diff --git a/README.md b/README.md new file mode 100644 index 0000000..88ddc94 --- /dev/null +++ b/README.md @@ -0,0 +1,52 @@ +# Kaffeekasse SaaS Neustart + +Frischer Neustart fuer eine mandantenfaehige Kaffeeliste mit: + +- klassischer Papierliste inklusive Nacherfassung +- digitaler Selbstbuchung fuer Browser, Tablet oder Kiosk +- vorbereitetem RFID-Pfad ueber Device-, Tag- und Event-Modelle +- Netcup-tauglichem PHP/MySQL-Betrieb ohne Root-Zwang +- produktionsnaher Vermarktungs- und Betriebsdokumentation + +## Projektziel + +Dieses Repository bildet eine pragmatische SaaS-Basis fuer mehrere Kunden oder +Mandanten. Jeder Tenant arbeitet unter `t/{slug}` in einer getrennten +Organisation, waehrend Produktlogik, Betrieb und Vermarktung zentral bleiben. + +## Kernentscheidungen + +- `PHP 8.x + MySQL` ohne schwere Runtime-Voraussetzungen +- `public/` als einziger Webroot +- `tenant_id` auf allen tenantbezogenen Fachtabellen +- ein gemeinsames Ledger fuer `digital`, `paper` und spaeter `rfid` +- Webspace-freundliche Cron- und CLI-Hooks statt Daemons + +## Hauptfunktionen + +- Plattform-Installation per Browser +- Self-Service-Registrierung fuer neue Mandanten +- Plattform-Admin mit Tenant-Uebersicht +- Tenant-Login mit Rollen `owner`, `admin`, `member` +- Mitglieder, Produkte und Preisgrundlagen +- digitale Buchungen und Einzahlungen +- Papierlisten als Entwurf, Zeilen und Ledger-Posting +- RFID-Geraete, Karten und Event-Inbox + +## Schnellstart lokal + +1. `.env.example` nach `.env` kopieren oder den Installer unter `/install` nutzen. +2. `database/schema.sql` per `bin/migrate.php` gegen MySQL ausfuehren. +3. Webroot auf `public/` zeigen lassen. +4. Plattform-Admin anlegen und anschliessend Mandanten registrieren. + +## Wichtige Dateien + +- [public/index.php](/config/workspace/kaffeeliste-neustart/public/index.php) +- [database/schema.sql](/config/workspace/kaffeeliste-neustart/database/schema.sql) +- [DEPLOY.md](/config/workspace/kaffeeliste-neustart/DEPLOY.md) +- [RUNBOOK.md](/config/workspace/kaffeeliste-neustart/RUNBOOK.md) +- [docs/product-strategy.md](/config/workspace/kaffeeliste-neustart/docs/product-strategy.md) +- [docs/marketing-ops.md](/config/workspace/kaffeeliste-neustart/docs/marketing-ops.md) +- [docs/production-blueprint.md](/config/workspace/kaffeeliste-neustart/docs/production-blueprint.md) +- [docs/rfid-roadmap.md](/config/workspace/kaffeeliste-neustart/docs/rfid-roadmap.md) diff --git a/RUNBOOK.md b/RUNBOOK.md new file mode 100644 index 0000000..e54fb27 --- /dev/null +++ b/RUNBOOK.md @@ -0,0 +1,37 @@ +# Runbook + +## Regelbetrieb + +- Installer nach dem ersten Setup nicht erneut oeffnen. +- Healthcheck vor und nach Releases pruefen. +- Cron-Lauf auf erfolgreiche Ausgabe und Lock-Freigabe kontrollieren. +- Backup-Plan fuer DB, `.env` und Uploads fest einplanen. + +## Monitoring-Minimum + +- Login-Fehler und Access-Denied-Ereignisse im Audit beobachten +- offene RFID-Events aus `bin/cron.php` beobachten +- Wachstum von `consumption_events`, `ledger_entries` und `audit_events` im Blick + behalten + +## Backup-Strategie + +- taegliches Datenbank-Backup +- Offsite-Kopie von `.env` und `storage/uploads` +- Vor jedem Release ein frisches DB-Backup + +## Wiederherstellung + +1. Letztes stabiles Release erneut deployen. +2. Datenbank-Backup einspielen. +3. `.env` und Uploads wiederherstellen. +4. Healthcheck ausfuehren. +5. Admin-Login, Tenant-Login und eine Testbuchung pruefen. + +## Offene Ausbaupfade + +- Mail-Verifikation und Einladungs-Workflow +- MFA fuer Owner/Admin +- Rate-Limits fuer Login und Registrierung +- automatisierte Export- und Reminder-Jobs +- regelbasierte Uebernahme von RFID-Events in echte Buchungen diff --git a/app/Controllers/PlatformController.php b/app/Controllers/PlatformController.php new file mode 100644 index 0000000..34ec734 --- /dev/null +++ b/app/Controllers/PlatformController.php @@ -0,0 +1,189 @@ +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.'); + } + } +} diff --git a/app/Controllers/TenantController.php b/app/Controllers/TenantController.php new file mode 100644 index 0000000..c9dfd14 --- /dev/null +++ b/app/Controllers/TenantController.php @@ -0,0 +1,636 @@ +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.'); + } + } +} diff --git a/app/Core/Auth.php b/app/Core/Auth.php new file mode 100644 index 0000000..d1442cf --- /dev/null +++ b/app/Core/Auth.php @@ -0,0 +1,108 @@ +pdo->prepare( + 'SELECT id, password_hash, platform_role FROM users WHERE email = :email LIMIT 1' + ); + $statement->execute(['email' => mb_strtolower($email)]); + $user = $statement->fetch(); + + if (!$user || !password_verify($password, $user['password_hash'])) { + return false; + } + + if (($user['platform_role'] ?? 'user') !== 'platform_admin') { + return false; + } + + $this->session->regenerate(); + $this->session->put('auth', [ + 'user_id' => (int) $user['id'], + 'platform_role' => $user['platform_role'], + ]); + + return true; + } + + public function attemptTenantLogin(string $tenantSlug, string $email, string $password): bool + { + $statement = $this->pdo->prepare( + 'SELECT u.id, + u.password_hash, + m.tenant_id, + m.role, + t.slug AS tenant_slug + FROM users u + INNER JOIN tenant_memberships m ON m.user_id = u.id AND m.status = "active" + INNER JOIN tenants t ON t.id = m.tenant_id AND t.slug = :slug AND t.status = "active" + WHERE u.email = :email + LIMIT 1' + ); + $statement->execute([ + 'slug' => $tenantSlug, + 'email' => mb_strtolower($email), + ]); + $membership = $statement->fetch(); + + if (!$membership || !password_verify($password, $membership['password_hash'])) { + return false; + } + + $this->session->regenerate(); + $this->session->put('auth', [ + 'user_id' => (int) $membership['id'], + 'tenant_id' => (int) $membership['tenant_id'], + 'tenant_slug' => $membership['tenant_slug'], + 'tenant_role' => $membership['role'], + 'platform_role' => 'user', + ]); + + return true; + } + + public function user(): ?array + { + $auth = $this->session->get('auth'); + + return is_array($auth) ? $auth : null; + } + + public function checkPlatformAdmin(): bool + { + return ($this->user()['platform_role'] ?? null) === 'platform_admin'; + } + + public function checkTenantAccess(string $tenantSlug): bool + { + $auth = $this->user(); + + return is_array($auth) + && isset($auth['tenant_slug']) + && hash_equals($auth['tenant_slug'], $tenantSlug); + } + + public function requireRole(array $allowedRoles): bool + { + $role = $this->user()['tenant_role'] ?? null; + + return is_string($role) && in_array($role, $allowedRoles, true); + } + + public function logout(): void + { + $this->session->invalidate(); + } +} diff --git a/app/Core/Autoloader.php b/app/Core/Autoloader.php new file mode 100644 index 0000000..aa798b4 --- /dev/null +++ b/app/Core/Autoloader.php @@ -0,0 +1,24 @@ +session->get('_csrf_token'); + + if (!is_string($token) || $token === '') { + $token = bin2hex(random_bytes(32)); + $this->session->put('_csrf_token', $token); + } + + return $token; + } + + public function validate(?string $token): bool + { + $expected = $this->session->get('_csrf_token'); + + return is_string($expected) && is_string($token) && hash_equals($expected, $token); + } +} diff --git a/app/Core/Database.php b/app/Core/Database.php new file mode 100644 index 0000000..d0295af --- /dev/null +++ b/app/Core/Database.php @@ -0,0 +1,51 @@ +pdo instanceof PDO) { + return $this->pdo; + } + + $dsn = sprintf( + 'mysql:host=%s;port=%d;dbname=%s;charset=%s', + $this->config['host'], + $this->config['port'], + $this->config['name'], + $this->config['charset'] + ); + + try { + $this->pdo = new PDO($dsn, $this->config['user'], $this->config['pass'], [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]); + } catch (PDOException $exception) { + throw new PDOException( + 'Database connection failed: ' . $exception->getMessage(), + (int) $exception->getCode(), + $exception + ); + } + + return $this->pdo; + } + + public function isConfigured(): bool + { + return $this->config['name'] !== '' && $this->config['user'] !== ''; + } +} diff --git a/app/Core/Env.php b/app/Core/Env.php new file mode 100644 index 0000000..7f5a35f --- /dev/null +++ b/app/Core/Env.php @@ -0,0 +1,34 @@ + true, + 'samesite' => 'Lax', + 'secure' => (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'), + 'path' => '/', + ]); + + session_start(); + } + } + + public function get(string $key, mixed $default = null): mixed + { + return $_SESSION[$key] ?? $default; + } + + public function put(string $key, mixed $value): void + { + $_SESSION[$key] = $value; + } + + public function forget(string $key): void + { + unset($_SESSION[$key]); + } + + public function flash(string $key, mixed $value): void + { + $_SESSION['_flash'][$key] = $value; + } + + public function pullFlash(string $key, mixed $default = null): mixed + { + $value = $_SESSION['_flash'][$key] ?? $default; + unset($_SESSION['_flash'][$key]); + + return $value; + } + + public function regenerate(): void + { + session_regenerate_id(true); + } + + public function invalidate(): void + { + $_SESSION = []; + + if (session_status() === PHP_SESSION_ACTIVE) { + session_destroy(); + } + } +} diff --git a/app/Core/TenantContext.php b/app/Core/TenantContext.php new file mode 100644 index 0000000..d61e4a3 --- /dev/null +++ b/app/Core/TenantContext.php @@ -0,0 +1,23 @@ +pdo->prepare( + 'SELECT * FROM tenants WHERE slug = :slug LIMIT 1' + ); + $statement->execute(['slug' => $slug]); + $tenant = $statement->fetch(); + + return $tenant ?: null; + } +} diff --git a/app/Core/View.php b/app/Core/View.php new file mode 100644 index 0000000..033b6be --- /dev/null +++ b/app/Core/View.php @@ -0,0 +1,30 @@ +rootPath . '/resources/views/' . $template . '.php'; + + if (!is_file($templateFile)) { + http_response_code(500); + echo 'View not found: ' . htmlspecialchars($template, ENT_QUOTES, 'UTF-8'); + return; + } + + $shared = $this->shared; + + extract($shared, EXTR_SKIP); + extract($data, EXTR_SKIP); + + require $templateFile; + } +} diff --git a/app/Services/AuditService.php b/app/Services/AuditService.php new file mode 100644 index 0000000..946f0a5 --- /dev/null +++ b/app/Services/AuditService.php @@ -0,0 +1,65 @@ +pdo->prepare( + 'INSERT INTO audit_events ( + tenant_id, + actor_user_id, + action, + target_type, + target_id, + result, + request_id, + ip_hash, + user_agent_hash, + meta_json + ) VALUES ( + :tenant_id, + :actor_user_id, + :action, + :target_type, + :target_id, + :result, + :request_id, + :ip_hash, + :user_agent_hash, + :meta_json + )' + ); + + $ip = $_SERVER['REMOTE_ADDR'] ?? ''; + $userAgent = $_SERVER['HTTP_USER_AGENT'] ?? ''; + $requestId = $_SERVER['HTTP_X_REQUEST_ID'] ?? bin2hex(random_bytes(18)); + + $statement->execute([ + 'tenant_id' => $tenantId, + 'actor_user_id' => $actorUserId, + 'action' => $action, + 'target_type' => $targetType, + 'target_id' => $targetId, + 'result' => $result, + 'request_id' => substr($requestId, 0, 36), + 'ip_hash' => $ip !== '' ? hash('sha256', $ip) : null, + 'user_agent_hash' => $userAgent !== '' ? hash('sha256', $userAgent) : null, + 'meta_json' => $meta !== [] ? json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null, + ]); + } +} diff --git a/app/Services/LedgerService.php b/app/Services/LedgerService.php new file mode 100644 index 0000000..6c0bbb3 --- /dev/null +++ b/app/Services/LedgerService.php @@ -0,0 +1,478 @@ +findSource($tenantId, $sourceCode); + $priceCents = $this->resolvePriceCents($tenantId, $productId, $effectiveAt); + $totalCents = (int) round($priceCents * $quantity); + $description = sprintf('Verbrauch %s x #%d', rtrim(rtrim(number_format($quantity, 2, '.', ''), '0'), '.'), $productId); + $recordedAt = gmdate('Y-m-d H:i:s'); + + $this->pdo->beginTransaction(); + + try { + $eventInsert = $this->pdo->prepare( + 'INSERT INTO consumption_events ( + tenant_id, + member_id, + product_id, + source_id, + paper_sheet_line_id, + quantity, + unit_price_cents, + total_cents, + effective_at, + recorded_at, + recorded_by_user_id, + source_reference, + notes + ) VALUES ( + :tenant_id, + :member_id, + :product_id, + :source_id, + :paper_sheet_line_id, + :quantity, + :unit_price_cents, + :total_cents, + :effective_at, + :recorded_at, + :recorded_by_user_id, + :source_reference, + :notes + )' + ); + $eventInsert->execute([ + 'tenant_id' => $tenantId, + 'member_id' => $memberId, + 'product_id' => $productId, + 'source_id' => $source['id'], + 'paper_sheet_line_id' => $paperSheetLineId, + 'quantity' => $quantity, + 'unit_price_cents' => $priceCents, + 'total_cents' => $totalCents, + 'effective_at' => $effectiveAt, + 'recorded_at' => $recordedAt, + 'recorded_by_user_id' => $recordedByUserId, + 'source_reference' => $sourceReference, + 'notes' => $notes, + ]); + $eventId = (int) $this->pdo->lastInsertId(); + + $entryInsert = $this->pdo->prepare( + 'INSERT INTO ledger_entries ( + tenant_id, + member_id, + source_id, + type, + reference_type, + reference_id, + description, + occurred_at, + created_by_user_id + ) VALUES ( + :tenant_id, + :member_id, + :source_id, + "consumption", + "consumption_event", + :reference_id, + :description, + :occurred_at, + :created_by_user_id + )' + ); + $entryInsert->execute([ + 'tenant_id' => $tenantId, + 'member_id' => $memberId, + 'source_id' => $source['id'], + 'reference_id' => $eventId, + 'description' => $description, + 'occurred_at' => $effectiveAt, + 'created_by_user_id' => $recordedByUserId, + ]); + $entryId = (int) $this->pdo->lastInsertId(); + + $lineInsert = $this->pdo->prepare( + 'INSERT INTO ledger_lines ( + ledger_entry_id, + tenant_id, + member_id, + account_code, + amount_cents + ) VALUES ( + :ledger_entry_id, + :tenant_id, + :member_id, + :account_code, + :amount_cents + )' + ); + + $lineInsert->execute([ + 'ledger_entry_id' => $entryId, + 'tenant_id' => $tenantId, + 'member_id' => $memberId, + 'account_code' => 'member_balance', + 'amount_cents' => $totalCents, + ]); + + $lineInsert->execute([ + 'ledger_entry_id' => $entryId, + 'tenant_id' => $tenantId, + 'member_id' => null, + 'account_code' => 'coffee_revenue', + 'amount_cents' => -$totalCents, + ]); + + $this->pdo->commit(); + } catch (\Throwable $throwable) { + $this->pdo->rollBack(); + throw $throwable; + } + + $this->audit->log($tenantId, $recordedByUserId, 'consumption.created', 'consumption_event', $eventId, 'success', [ + 'member_id' => $memberId, + 'product_id' => $productId, + 'quantity' => $quantity, + 'source' => $sourceCode, + 'total_cents' => $totalCents, + ]); + } + + public function createPayment( + int $tenantId, + int $memberId, + int $amountCents, + string $occurredAt, + int $recordedByUserId, + ?string $notes = null + ): void { + if ($amountCents <= 0) { + throw new RuntimeException('Der Einzahlungsbetrag muss groesser als 0 sein.'); + } + + $source = $this->findSource($tenantId, 'admin_backoffice'); + + $entryInsert = $this->pdo->prepare( + 'INSERT INTO ledger_entries ( + tenant_id, + member_id, + source_id, + type, + description, + occurred_at, + created_by_user_id, + reference_type + ) VALUES ( + :tenant_id, + :member_id, + :source_id, + "payment", + :description, + :occurred_at, + :created_by_user_id, + "payment" + )' + ); + + $this->pdo->beginTransaction(); + + try { + $entryInsert->execute([ + 'tenant_id' => $tenantId, + 'member_id' => $memberId, + 'source_id' => $source['id'], + 'description' => $notes !== null && trim($notes) !== '' ? $notes : 'Einzahlung', + 'occurred_at' => $occurredAt, + 'created_by_user_id' => $recordedByUserId, + ]); + + $entryId = (int) $this->pdo->lastInsertId(); + + $lineInsert = $this->pdo->prepare( + 'INSERT INTO ledger_lines ( + ledger_entry_id, + tenant_id, + member_id, + account_code, + amount_cents + ) VALUES ( + :ledger_entry_id, + :tenant_id, + :member_id, + :account_code, + :amount_cents + )' + ); + + $lineInsert->execute([ + 'ledger_entry_id' => $entryId, + 'tenant_id' => $tenantId, + 'member_id' => null, + 'account_code' => 'cashbox', + 'amount_cents' => $amountCents, + ]); + + $lineInsert->execute([ + 'ledger_entry_id' => $entryId, + 'tenant_id' => $tenantId, + 'member_id' => $memberId, + 'account_code' => 'member_balance', + 'amount_cents' => -$amountCents, + ]); + + $this->pdo->commit(); + } catch (\Throwable $throwable) { + $this->pdo->rollBack(); + throw $throwable; + } + + $this->audit->log($tenantId, $recordedByUserId, 'payment.created', 'ledger_entry', $entryId, 'success', [ + 'member_id' => $memberId, + 'amount_cents' => $amountCents, + ]); + } + + public function createPaperSheet( + int $tenantId, + string $title, + ?string $periodLabel, + ?string $periodStart, + ?string $periodEnd, + ?string $notes, + int $createdByUserId + ): int { + $statement = $this->pdo->prepare( + 'INSERT INTO paper_sheets ( + tenant_id, + title, + period_label, + period_start, + period_end, + notes, + created_by_user_id + ) VALUES ( + :tenant_id, + :title, + :period_label, + :period_start, + :period_end, + :notes, + :created_by_user_id + )' + ); + $statement->execute([ + 'tenant_id' => $tenantId, + 'title' => $title, + 'period_label' => $periodLabel, + 'period_start' => $periodStart ?: null, + 'period_end' => $periodEnd ?: null, + 'notes' => $notes, + 'created_by_user_id' => $createdByUserId, + ]); + + $sheetId = (int) $this->pdo->lastInsertId(); + + $this->audit->log($tenantId, $createdByUserId, 'paper_sheet.created', 'paper_sheet', $sheetId, 'success'); + + return $sheetId; + } + + public function addPaperSheetLine( + int $tenantId, + int $sheetId, + int $memberId, + int $productId, + float $quantity, + ?string $effectiveAt, + ?string $note, + int $actorUserId + ): void { + if ($quantity <= 0) { + throw new RuntimeException('Die Menge muss groesser als 0 sein.'); + } + + $sheet = $this->pdo->prepare( + 'SELECT id, status FROM paper_sheets WHERE id = :id AND tenant_id = :tenant_id LIMIT 1' + ); + $sheet->execute([ + 'id' => $sheetId, + 'tenant_id' => $tenantId, + ]); + $paperSheet = $sheet->fetch(); + + if (!$paperSheet) { + throw new RuntimeException('Die Papierliste wurde nicht gefunden.'); + } + + if ($paperSheet['status'] !== 'draft') { + throw new RuntimeException('Nur Entwuerfe koennen erweitert werden.'); + } + + $statement = $this->pdo->prepare( + 'INSERT INTO paper_sheet_lines ( + paper_sheet_id, + tenant_id, + member_id, + product_id, + quantity, + effective_at, + note + ) VALUES ( + :paper_sheet_id, + :tenant_id, + :member_id, + :product_id, + :quantity, + :effective_at, + :note + )' + ); + $statement->execute([ + 'paper_sheet_id' => $sheetId, + 'tenant_id' => $tenantId, + 'member_id' => $memberId, + 'product_id' => $productId, + 'quantity' => $quantity, + 'effective_at' => $effectiveAt ?: null, + 'note' => $note, + ]); + + $lineId = (int) $this->pdo->lastInsertId(); + + $this->audit->log($tenantId, $actorUserId, 'paper_sheet.line_added', 'paper_sheet_line', $lineId, 'success', [ + 'paper_sheet_id' => $sheetId, + ]); + } + + public function postPaperSheet(int $tenantId, int $sheetId, int $actorUserId): void + { + $sheetStatement = $this->pdo->prepare( + 'SELECT id, status FROM paper_sheets WHERE id = :id AND tenant_id = :tenant_id LIMIT 1' + ); + $sheetStatement->execute([ + 'id' => $sheetId, + 'tenant_id' => $tenantId, + ]); + $sheet = $sheetStatement->fetch(); + + if (!$sheet) { + throw new RuntimeException('Die Papierliste wurde nicht gefunden.'); + } + + if ($sheet['status'] !== 'draft') { + throw new RuntimeException('Die Papierliste wurde bereits verbucht.'); + } + + $linesStatement = $this->pdo->prepare( + 'SELECT * FROM paper_sheet_lines WHERE paper_sheet_id = :paper_sheet_id AND tenant_id = :tenant_id ORDER BY id ASC' + ); + $linesStatement->execute([ + 'paper_sheet_id' => $sheetId, + 'tenant_id' => $tenantId, + ]); + $lines = $linesStatement->fetchAll(); + + if ($lines === []) { + throw new RuntimeException('Die Papierliste enthaelt noch keine Positionen.'); + } + + foreach ($lines as $line) { + $this->createConsumption( + $tenantId, + (int) $line['member_id'], + (int) $line['product_id'], + 'paper_sheet', + $line['effective_at'] ?: gmdate('Y-m-d H:i:s'), + $actorUserId, + (float) $line['quantity'], + $line['note'] ?: 'Papierliste', + (int) $line['id'], + 'sheet:' . $sheetId + ); + } + + $update = $this->pdo->prepare( + 'UPDATE paper_sheets SET status = "posted", posted_at = :posted_at WHERE id = :id AND tenant_id = :tenant_id' + ); + $update->execute([ + 'posted_at' => gmdate('Y-m-d H:i:s'), + 'id' => $sheetId, + 'tenant_id' => $tenantId, + ]); + + $this->audit->log($tenantId, $actorUserId, 'paper_sheet.posted', 'paper_sheet', $sheetId, 'success'); + } + + private function resolvePriceCents(int $tenantId, int $productId, string $effectiveAt): int + { + $statement = $this->pdo->prepare( + 'SELECT price_cents + FROM product_prices + WHERE tenant_id = :tenant_id + AND product_id = :product_id + AND valid_from <= :effective_at + AND (valid_until IS NULL OR valid_until > :effective_at) + ORDER BY valid_from DESC + LIMIT 1' + ); + $statement->execute([ + 'tenant_id' => $tenantId, + 'product_id' => $productId, + 'effective_at' => $effectiveAt, + ]); + $price = $statement->fetchColumn(); + + if ($price === false) { + throw new RuntimeException('Fuer das Produkt ist kein aktiver Preis hinterlegt.'); + } + + return (int) $price; + } + + private function findSource(int $tenantId, string $code): array + { + $statement = $this->pdo->prepare( + 'SELECT id, code FROM capture_sources WHERE tenant_id = :tenant_id AND code = :code LIMIT 1' + ); + $statement->execute([ + 'tenant_id' => $tenantId, + 'code' => $code, + ]); + $source = $statement->fetch(); + + if (!$source) { + throw new RuntimeException('Die Erfassungsquelle wurde nicht gefunden.'); + } + + return $source; + } +} diff --git a/app/Services/RfidService.php b/app/Services/RfidService.php new file mode 100644 index 0000000..bc8d9a3 --- /dev/null +++ b/app/Services/RfidService.php @@ -0,0 +1,130 @@ +pdo->prepare( + 'INSERT INTO rfid_devices (tenant_id, name, location_label, device_token, status) + VALUES (:tenant_id, :name, :location_label, :device_token, "planned")' + ); + $statement->execute([ + 'tenant_id' => $tenantId, + 'name' => trim($name), + 'location_label' => $location, + 'device_token' => $token, + ]); + $deviceId = (int) $this->pdo->lastInsertId(); + + $this->audit->log($tenantId, $actorUserId, 'rfid.device_created', 'rfid_device', $deviceId, 'success'); + + return $token; + } + + public function assignTag(int $tenantId, int $memberId, string $uid, ?string $label, int $actorUserId): void + { + if (trim($uid) === '') { + throw new RuntimeException('Die Karten-UID darf nicht leer sein.'); + } + + $statement = $this->pdo->prepare( + 'INSERT INTO rfid_tags (tenant_id, member_id, uid_hash, label, status) + VALUES (:tenant_id, :member_id, :uid_hash, :label, "active")' + ); + $statement->execute([ + 'tenant_id' => $tenantId, + 'member_id' => $memberId, + 'uid_hash' => hash('sha256', trim($uid)), + 'label' => $label, + ]); + + $this->audit->log($tenantId, $actorUserId, 'rfid.tag_assigned', 'rfid_tag', (int) $this->pdo->lastInsertId(), 'success'); + } + + public function ingest(array $payload): void + { + $deviceToken = trim((string) ($payload['device_token'] ?? '')); + $uid = trim((string) ($payload['uid'] ?? '')); + $externalEventId = trim((string) ($payload['event_id'] ?? '')); + + if ($deviceToken === '' || $uid === '') { + throw new RuntimeException('device_token und uid sind Pflichtfelder.'); + } + + $deviceStatement = $this->pdo->prepare( + 'SELECT d.id, d.tenant_id, d.status + FROM rfid_devices d + WHERE d.device_token = :device_token + LIMIT 1' + ); + $deviceStatement->execute(['device_token' => $deviceToken]); + $device = $deviceStatement->fetch(); + + if (!$device) { + throw new RuntimeException('Das RFID-Geraet ist unbekannt.'); + } + + $status = 'received'; + $tagStatement = $this->pdo->prepare( + 'SELECT id FROM rfid_tags WHERE tenant_id = :tenant_id AND uid_hash = :uid_hash LIMIT 1' + ); + $tagStatement->execute([ + 'tenant_id' => $device['tenant_id'], + 'uid_hash' => hash('sha256', $uid), + ]); + + if ($tagStatement->fetch()) { + $status = 'linked'; + } + + $statement = $this->pdo->prepare( + 'INSERT INTO rfid_events ( + tenant_id, + device_id, + external_event_id, + uid_hash, + payload_json, + status, + received_at + ) VALUES ( + :tenant_id, + :device_id, + :external_event_id, + :uid_hash, + :payload_json, + :status, + :received_at + )' + ); + $statement->execute([ + 'tenant_id' => $device['tenant_id'], + 'device_id' => $device['id'], + 'external_event_id' => $externalEventId !== '' ? $externalEventId : null, + 'uid_hash' => hash('sha256', $uid), + 'payload_json' => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'status' => $status, + 'received_at' => gmdate('Y-m-d H:i:s'), + ]); + + $this->audit->log((int) $device['tenant_id'], null, 'rfid.event_received', 'rfid_event', (int) $this->pdo->lastInsertId(), 'success', [ + 'status' => $status, + 'device_id' => (int) $device['id'], + ]); + } +} diff --git a/app/Services/SetupService.php b/app/Services/SetupService.php new file mode 100644 index 0000000..68b6551 --- /dev/null +++ b/app/Services/SetupService.php @@ -0,0 +1,132 @@ +rootPath . '/storage/installed.lock'); + } + + public function install(array $input): void + { + $required = [ + 'app_url', + 'db_host', + 'db_port', + 'db_name', + 'db_user', + 'admin_name', + 'admin_email', + 'admin_password', + ]; + + foreach ($required as $field) { + if (trim((string) ($input[$field] ?? '')) === '') { + throw new RuntimeException('Bitte alle Pflichtfelder ausfuellen.'); + } + } + + if (!filter_var($input['admin_email'], FILTER_VALIDATE_EMAIL)) { + throw new RuntimeException('Die Admin-E-Mail ist ungueltig.'); + } + + if (mb_strlen($input['admin_password']) < 12) { + throw new RuntimeException('Das Admin-Passwort muss mindestens 12 Zeichen lang sein.'); + } + + $config = [ + 'host' => trim((string) $input['db_host']), + 'port' => (int) $input['db_port'], + 'name' => trim((string) $input['db_name']), + 'user' => trim((string) $input['db_user']), + 'pass' => (string) ($input['db_pass'] ?? ''), + 'charset' => 'utf8mb4', + ]; + + $dsn = sprintf( + 'mysql:host=%s;port=%d;dbname=%s;charset=%s', + $config['host'], + $config['port'], + $config['name'], + $config['charset'] + ); + + $pdo = new PDO($dsn, $config['user'], $config['pass'], [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]); + + $schema = file_get_contents($this->rootPath . '/database/schema.sql'); + + if (!is_string($schema) || $schema === '') { + throw new RuntimeException('Das Datenbankschema konnte nicht geladen werden.'); + } + + $pdo->exec($schema); + + $statement = $pdo->prepare('SELECT id FROM users WHERE email = :email LIMIT 1'); + $statement->execute(['email' => mb_strtolower((string) $input['admin_email'])]); + $existing = $statement->fetchColumn(); + + if (!$existing) { + $insert = $pdo->prepare( + 'INSERT INTO users (full_name, email, password_hash, platform_role) + VALUES (:full_name, :email, :password_hash, "platform_admin")' + ); + $insert->execute([ + 'full_name' => trim((string) $input['admin_name']), + 'email' => mb_strtolower((string) $input['admin_email']), + 'password_hash' => secure_password_hash((string) $input['admin_password']), + ]); + } + + $envContent = $this->buildEnvFile($input); + + if (file_put_contents($this->rootPath . '/.env', $envContent) === false) { + throw new RuntimeException('Die .env-Datei konnte nicht geschrieben werden.'); + } + + $lockContent = json_encode([ + 'installed_at' => gmdate(DATE_ATOM), + 'app_url' => trim((string) $input['app_url']), + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + + file_put_contents($this->rootPath . '/storage/installed.lock', (string) $lockContent); + } + + private function buildEnvFile(array $input): string + { + $appKey = bin2hex(random_bytes(32)); + $rfidSecret = bin2hex(random_bytes(24)); + + $lines = [ + 'APP_NAME="Kaffeekasse SaaS"', + 'APP_ENV=production', + 'APP_DEBUG=0', + 'APP_URL=' . trim((string) $input['app_url']), + 'APP_TIMEZONE=Europe/Berlin', + 'APP_KEY=' . $appKey, + '', + 'DB_HOST=' . trim((string) $input['db_host']), + 'DB_PORT=' . (int) $input['db_port'], + 'DB_NAME=' . trim((string) $input['db_name']), + 'DB_USER=' . trim((string) $input['db_user']), + 'DB_PASS=' . (string) ($input['db_pass'] ?? ''), + '', + 'MAIL_FROM=' . trim((string) ($input['mail_from'] ?? 'noreply@example.com')), + 'RFID_SHARED_SECRET=' . $rfidSecret, + ]; + + return implode(PHP_EOL, $lines) . PHP_EOL; + } +} diff --git a/app/Services/TenantRegistrationService.php b/app/Services/TenantRegistrationService.php new file mode 100644 index 0000000..582b76e --- /dev/null +++ b/app/Services/TenantRegistrationService.php @@ -0,0 +1,197 @@ +slugify((string) ($input['tenant_slug'] ?? $name)); + $ownerName = trim((string) ($input['owner_name'] ?? '')); + $ownerEmail = mb_strtolower(trim((string) ($input['owner_email'] ?? ''))); + $ownerPassword = (string) ($input['owner_password'] ?? ''); + $plan = (string) ($input['plan'] ?? 'starter'); + $defaultPriceCents = max(50, (int) round(((float) ($input['default_price_eur'] ?? 1.20)) * 100)); + + if ($name === '' || $slug === '' || $ownerName === '' || $ownerEmail === '' || $ownerPassword === '') { + throw new RuntimeException('Bitte alle Pflichtfelder ausfuellen.'); + } + + if (!filter_var($ownerEmail, FILTER_VALIDATE_EMAIL)) { + throw new RuntimeException('Die E-Mail-Adresse ist ungueltig.'); + } + + if (mb_strlen($ownerPassword) < 12) { + throw new RuntimeException('Das Passwort muss mindestens 12 Zeichen lang sein.'); + } + + $allowedPlans = ['starter', 'team', 'business']; + + if (!in_array($plan, $allowedPlans, true)) { + $plan = 'starter'; + } + + $existingTenant = $this->pdo->prepare('SELECT id FROM tenants WHERE slug = :slug LIMIT 1'); + $existingTenant->execute(['slug' => $slug]); + + if ($existingTenant->fetchColumn()) { + throw new RuntimeException('Der gewuenschte Kurzname ist bereits vergeben.'); + } + + $existingUser = $this->pdo->prepare('SELECT id FROM users WHERE email = :email LIMIT 1'); + $existingUser->execute(['email' => $ownerEmail]); + + if ($existingUser->fetchColumn()) { + throw new RuntimeException('Die E-Mail-Adresse ist bereits vorhanden. Fuer weitere Tenants bitte spaeter Einladungen nutzen.'); + } + + $now = gmdate('Y-m-d H:i:s'); + + $this->pdo->beginTransaction(); + + try { + $userInsert = $this->pdo->prepare( + 'INSERT INTO users (full_name, email, password_hash, platform_role) + VALUES (:full_name, :email, :password_hash, "user")' + ); + $userInsert->execute([ + 'full_name' => $ownerName, + 'email' => $ownerEmail, + 'password_hash' => secure_password_hash($ownerPassword), + ]); + $userId = (int) $this->pdo->lastInsertId(); + + $tenantInsert = $this->pdo->prepare( + 'INSERT INTO tenants (name, slug, plan, status, created_at) + VALUES (:name, :slug, :plan, "active", :created_at)' + ); + $tenantInsert->execute([ + 'name' => $name, + 'slug' => $slug, + 'plan' => $plan, + 'created_at' => $now, + ]); + $tenantId = (int) $this->pdo->lastInsertId(); + + $membershipInsert = $this->pdo->prepare( + 'INSERT INTO tenant_memberships (tenant_id, user_id, role, status) + VALUES (:tenant_id, :user_id, "owner", "active")' + ); + $membershipInsert->execute([ + 'tenant_id' => $tenantId, + 'user_id' => $userId, + ]); + + $settingsInsert = $this->pdo->prepare( + 'INSERT INTO tenant_settings ( + tenant_id, + currency_code, + default_product_price_cents, + allow_self_service, + allow_paper_lists, + allow_rfid + ) VALUES ( + :tenant_id, + "EUR", + :default_product_price_cents, + 1, + 1, + 0 + )' + ); + $settingsInsert->execute([ + 'tenant_id' => $tenantId, + 'default_product_price_cents' => $defaultPriceCents, + ]); + + $memberInsert = $this->pdo->prepare( + 'INSERT INTO members (tenant_id, user_id, display_name, email, status) + VALUES (:tenant_id, :user_id, :display_name, :email, "active")' + ); + $memberInsert->execute([ + 'tenant_id' => $tenantId, + 'user_id' => $userId, + 'display_name' => $ownerName, + 'email' => $ownerEmail, + ]); + + $sourceInsert = $this->pdo->prepare( + 'INSERT INTO capture_sources (tenant_id, code, name, channel, is_system) + VALUES (:tenant_id, :code, :name, :channel, 1)' + ); + + foreach ([ + ['digital_self', 'Digitale Selbstbuchung', 'digital'], + ['paper_sheet', 'Papierliste / Nacherfassung', 'paper'], + ['admin_backoffice', 'Backoffice-Buchung', 'admin'], + ['rfid_reader', 'RFID-Geraet', 'rfid'], + ] as [$code, $sourceName, $channel]) { + $sourceInsert->execute([ + 'tenant_id' => $tenantId, + 'code' => $code, + 'name' => $sourceName, + 'channel' => $channel, + ]); + } + + foreach ([ + ['Kaffee', $defaultPriceCents], + ['Espresso', $defaultPriceCents], + ['Tee', max(80, $defaultPriceCents - 20)], + ] as [$productName, $priceCents]) { + $productInsert = $this->pdo->prepare( + 'INSERT INTO products (tenant_id, name, sku, is_active) + VALUES (:tenant_id, :name, :sku, 1)' + ); + $productInsert->execute([ + 'tenant_id' => $tenantId, + 'name' => $productName, + 'sku' => strtolower(str_replace(' ', '-', $productName)), + ]); + $productId = (int) $this->pdo->lastInsertId(); + + $priceInsert = $this->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' => $tenantId, + 'product_id' => $productId, + 'price_cents' => $priceCents, + 'valid_from' => $now, + ]); + } + + $this->pdo->commit(); + } catch (\Throwable $throwable) { + $this->pdo->rollBack(); + throw $throwable; + } + + $this->audit->log($tenantId, $userId, 'tenant.registered', 'tenant', $tenantId, 'success', [ + 'plan' => $plan, + 'slug' => $slug, + ]); + + return $slug; + } + + private function slugify(string $value): string + { + $value = mb_strtolower($value); + $value = preg_replace('/[^a-z0-9]+/u', '-', $value) ?? ''; + $value = trim($value, '-'); + + return $value; + } +} diff --git a/app/Support/helpers.php b/app/Support/helpers.php new file mode 100644 index 0000000..8c9f630 --- /dev/null +++ b/app/Support/helpers.php @@ -0,0 +1,109 @@ +token()) . '">'; +} + +function flash(Session $session, string $key, mixed $default = null): mixed +{ + return $session->pullFlash($key, $default); +} + +function tenant_url(string $tenantSlug, string $path = ''): string +{ + $suffix = $path === '' ? '' : '/' . ltrim($path, '/'); + + return '/t/' . rawurlencode($tenantSlug) . $suffix; +} + +function money_from_cents(int $amountCents): string +{ + return number_format($amountCents / 100, 2, ',', '.') . ' EUR'; +} + +function secure_password_hash(string $password): string +{ + $algo = defined('PASSWORD_ARGON2ID') ? PASSWORD_ARGON2ID : PASSWORD_BCRYPT; + + return password_hash($password, $algo); +} + +function normalize_datetime_input(?string $value): string +{ + $value = trim((string) $value); + + if ($value === '') { + return gmdate('Y-m-d H:i:s'); + } + + $normalized = str_replace('T', ' ', $value); + + if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $normalized)) { + return $normalized . ':00'; + } + + return $normalized; +} + +function request_origin_matches_host(): bool +{ + $host = $_SERVER['HTTP_HOST'] ?? ''; + + if ($host === '') { + return true; + } + + foreach (['HTTP_ORIGIN', 'HTTP_REFERER'] as $header) { + if (!empty($_SERVER[$header])) { + $originHost = parse_url((string) $_SERVER[$header], PHP_URL_HOST); + + if (is_string($originHost) && $originHost !== '' && !hash_equals($host, $originHost)) { + return false; + } + } + } + + return true; +} diff --git a/app/bootstrap.php b/app/bootstrap.php new file mode 100644 index 0000000..12115da --- /dev/null +++ b/app/bootstrap.php @@ -0,0 +1,47 @@ +start(); + +$csrf = new Csrf($session); +$database = new Database($config['db']); +$view = new View($rootPath, [ + 'config' => $config, + 'session' => $session, + 'csrf' => $csrf, +]); + +return [ + 'rootPath' => $rootPath, + 'config' => $config, + 'session' => $session, + 'csrf' => $csrf, + 'database' => $database, + 'view' => $view, +]; diff --git a/bin/cron.php b/bin/cron.php new file mode 100644 index 0000000..8c38b4d --- /dev/null +++ b/bin/cron.php @@ -0,0 +1,44 @@ +isConfigured()) { + throw new RuntimeException('Database config missing.'); + } + + $pdo = $app['database']->pdo(); + + $pendingRfid = (int) $pdo->query( + 'SELECT COUNT(*) FROM rfid_events WHERE status = "received"' + )->fetchColumn(); + + $summary = [ + 'timestamp' => gmdate(DATE_ATOM), + 'pending_rfid_events' => $pendingRfid, + 'notes' => [ + 'In diesem MVP dient Cron als technischer Haken fuer spaetere Reminder, Exporte und RFID-Regeln.', + ], + ]; + + fwrite(STDOUT, json_encode($summary, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL); +} catch (\Throwable $throwable) { + fwrite(STDERR, $throwable->getMessage() . PHP_EOL); + exit(1); +} finally { + if (is_file($lockFile)) { + unlink($lockFile); + } +} diff --git a/bin/healthcheck.php b/bin/healthcheck.php new file mode 100644 index 0000000..1d4f654 --- /dev/null +++ b/bin/healthcheck.php @@ -0,0 +1,27 @@ + $app['config']['env'], + 'database_configured' => $app['database']->isConfigured(), + 'installed' => is_file(dirname(__DIR__) . '/storage/installed.lock'), +]; + +try { + if ($status['database_configured']) { + $count = $app['database']->pdo()->query('SELECT COUNT(*) FROM tenants')->fetchColumn(); + $status['database_reachable'] = true; + $status['tenant_count'] = (int) $count; + } else { + $status['database_reachable'] = false; + } +} catch (\Throwable $throwable) { + $status['database_reachable'] = false; + $status['error'] = $throwable->getMessage(); +} + +header('Content-Type: application/json'); +echo json_encode($status, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL; diff --git a/bin/migrate.php b/bin/migrate.php new file mode 100644 index 0000000..0ec4b77 --- /dev/null +++ b/bin/migrate.php @@ -0,0 +1,20 @@ +isConfigured()) { + fwrite(STDERR, "Database config missing.\n"); + exit(1); +} + +$schema = file_get_contents(dirname(__DIR__) . '/database/schema.sql'); + +if (!is_string($schema) || $schema === '') { + fwrite(STDERR, "Schema missing.\n"); + exit(1); +} + +$app['database']->pdo()->exec($schema); +fwrite(STDOUT, "Schema applied successfully.\n"); diff --git a/config/app.php b/config/app.php new file mode 100644 index 0000000..ecb8d32 --- /dev/null +++ b/config/app.php @@ -0,0 +1,19 @@ + $_ENV['APP_NAME'] ?? 'Kaffeekasse SaaS', + 'env' => $_ENV['APP_ENV'] ?? 'production', + 'debug' => (($_ENV['APP_DEBUG'] ?? '0') === '1'), + 'url' => rtrim($_ENV['APP_URL'] ?? 'http://localhost:8080', '/'), + 'timezone' => $_ENV['APP_TIMEZONE'] ?? 'Europe/Berlin', + 'app_key' => $_ENV['APP_KEY'] ?? '', + 'db' => [ + 'host' => $_ENV['DB_HOST'] ?? '127.0.0.1', + 'port' => (int) ($_ENV['DB_PORT'] ?? 3306), + 'name' => $_ENV['DB_NAME'] ?? '', + 'user' => $_ENV['DB_USER'] ?? '', + 'pass' => $_ENV['DB_PASS'] ?? '', + 'charset' => 'utf8mb4', + ], + 'rfid_shared_secret' => $_ENV['RFID_SHARED_SECRET'] ?? '', +]; diff --git a/database/schema.sql b/database/schema.sql new file mode 100644 index 0000000..6923ffc --- /dev/null +++ b/database/schema.sql @@ -0,0 +1,248 @@ +CREATE TABLE IF NOT EXISTS users ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + full_name VARCHAR(160) NOT NULL, + email VARCHAR(190) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + platform_role ENUM('platform_admin', 'user') NOT NULL DEFAULT 'user', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS tenants ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(190) NOT NULL, + slug VARCHAR(120) NOT NULL UNIQUE, + plan ENUM('starter', 'team', 'business') NOT NULL DEFAULT 'starter', + status ENUM('trial', 'active', 'suspended') NOT NULL DEFAULT 'trial', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS tenant_domains ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + host VARCHAR(190) NOT NULL UNIQUE, + is_primary TINYINT(1) NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_tenant_domains_tenant FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS tenant_memberships ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + role ENUM('owner', 'admin', 'member', 'device') NOT NULL DEFAULT 'member', + status ENUM('active', 'invited', 'disabled') NOT NULL DEFAULT 'active', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uniq_tenant_user (tenant_id, user_id), + CONSTRAINT fk_memberships_tenant FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE, + CONSTRAINT fk_memberships_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS tenant_settings ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + currency_code CHAR(3) NOT NULL DEFAULT 'EUR', + default_product_price_cents INT NOT NULL DEFAULT 120, + allow_self_service TINYINT(1) NOT NULL DEFAULT 1, + allow_paper_lists TINYINT(1) NOT NULL DEFAULT 1, + allow_rfid TINYINT(1) NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uniq_tenant_settings (tenant_id), + CONSTRAINT fk_tenant_settings_tenant FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS members ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NULL, + display_name VARCHAR(160) NOT NULL, + email VARCHAR(190) NULL, + access_pin VARCHAR(20) NULL, + status ENUM('active', 'archived') NOT NULL DEFAULT 'active', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uniq_member_email (tenant_id, email), + CONSTRAINT fk_members_tenant FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE, + CONSTRAINT fk_members_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS products ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + name VARCHAR(120) NOT NULL, + sku VARCHAR(60) NULL, + is_active TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_products_tenant FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS product_prices ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + product_id BIGINT UNSIGNED NOT NULL, + price_cents INT NOT NULL, + valid_from DATETIME NOT NULL, + valid_until DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_product_prices_tenant FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE, + CONSTRAINT fk_product_prices_product FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS capture_sources ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + code VARCHAR(60) NOT NULL, + name VARCHAR(120) NOT NULL, + channel ENUM('paper', 'digital', 'admin', 'rfid') NOT NULL, + is_system TINYINT(1) NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uniq_capture_source_code (tenant_id, code), + CONSTRAINT fk_capture_sources_tenant FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS paper_sheets ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + title VARCHAR(190) NOT NULL, + period_label VARCHAR(120) NULL, + period_start DATE NULL, + period_end DATE NULL, + status ENUM('draft', 'posted') NOT NULL DEFAULT 'draft', + notes TEXT NULL, + created_by_user_id BIGINT UNSIGNED NULL, + posted_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_paper_sheets_tenant FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE, + CONSTRAINT fk_paper_sheets_user FOREIGN KEY (created_by_user_id) REFERENCES users (id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS paper_sheet_lines ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + paper_sheet_id BIGINT UNSIGNED NOT NULL, + tenant_id BIGINT UNSIGNED NOT NULL, + member_id BIGINT UNSIGNED NOT NULL, + product_id BIGINT UNSIGNED NOT NULL, + quantity DECIMAL(8,2) NOT NULL DEFAULT 1.00, + effective_at DATETIME NULL, + note VARCHAR(255) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_paper_sheet_lines_sheet FOREIGN KEY (paper_sheet_id) REFERENCES paper_sheets (id) ON DELETE CASCADE, + CONSTRAINT fk_paper_sheet_lines_tenant FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE, + CONSTRAINT fk_paper_sheet_lines_member FOREIGN KEY (member_id) REFERENCES members (id) ON DELETE CASCADE, + CONSTRAINT fk_paper_sheet_lines_product FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS consumption_events ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + member_id BIGINT UNSIGNED NOT NULL, + product_id BIGINT UNSIGNED NOT NULL, + source_id BIGINT UNSIGNED NOT NULL, + paper_sheet_line_id BIGINT UNSIGNED NULL, + quantity DECIMAL(8,2) NOT NULL DEFAULT 1.00, + unit_price_cents INT NOT NULL, + total_cents INT NOT NULL, + effective_at DATETIME NOT NULL, + recorded_at DATETIME NOT NULL, + recorded_by_user_id BIGINT UNSIGNED NULL, + source_reference VARCHAR(120) NULL, + notes TEXT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_consumption_events_tenant FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE, + CONSTRAINT fk_consumption_events_member FOREIGN KEY (member_id) REFERENCES members (id) ON DELETE CASCADE, + CONSTRAINT fk_consumption_events_product FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE, + CONSTRAINT fk_consumption_events_source FOREIGN KEY (source_id) REFERENCES capture_sources (id) ON DELETE CASCADE, + CONSTRAINT fk_consumption_events_sheet_line FOREIGN KEY (paper_sheet_line_id) REFERENCES paper_sheet_lines (id) ON DELETE SET NULL, + CONSTRAINT fk_consumption_events_recorded_by FOREIGN KEY (recorded_by_user_id) REFERENCES users (id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS ledger_entries ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + member_id BIGINT UNSIGNED NULL, + source_id BIGINT UNSIGNED NULL, + type ENUM('consumption', 'payment', 'correction', 'credit', 'opening_balance') NOT NULL, + reference_type VARCHAR(60) NULL, + reference_id BIGINT UNSIGNED NULL, + description VARCHAR(255) NOT NULL, + occurred_at DATETIME NOT NULL, + created_by_user_id BIGINT UNSIGNED NULL, + reversal_of_entry_id BIGINT UNSIGNED NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_ledger_entries_tenant FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE, + CONSTRAINT fk_ledger_entries_member FOREIGN KEY (member_id) REFERENCES members (id) ON DELETE SET NULL, + CONSTRAINT fk_ledger_entries_source FOREIGN KEY (source_id) REFERENCES capture_sources (id) ON DELETE SET NULL, + CONSTRAINT fk_ledger_entries_user FOREIGN KEY (created_by_user_id) REFERENCES users (id) ON DELETE SET NULL, + CONSTRAINT fk_ledger_entries_reversal FOREIGN KEY (reversal_of_entry_id) REFERENCES ledger_entries (id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS ledger_lines ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + ledger_entry_id BIGINT UNSIGNED NOT NULL, + tenant_id BIGINT UNSIGNED NOT NULL, + member_id BIGINT UNSIGNED NULL, + account_code ENUM('member_balance', 'cashbox', 'coffee_revenue') NOT NULL, + amount_cents INT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_ledger_lines_entry FOREIGN KEY (ledger_entry_id) REFERENCES ledger_entries (id) ON DELETE CASCADE, + CONSTRAINT fk_ledger_lines_tenant FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE, + CONSTRAINT fk_ledger_lines_member FOREIGN KEY (member_id) REFERENCES members (id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS rfid_devices ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + name VARCHAR(120) NOT NULL, + location_label VARCHAR(160) NULL, + device_token VARCHAR(80) NOT NULL UNIQUE, + status ENUM('planned', 'active', 'disabled') NOT NULL DEFAULT 'planned', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_rfid_devices_tenant FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS rfid_tags ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + member_id BIGINT UNSIGNED NULL, + uid_hash CHAR(64) NOT NULL, + label VARCHAR(120) NULL, + status ENUM('active', 'lost', 'retired') NOT NULL DEFAULT 'active', + last_seen_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uniq_rfid_tag (tenant_id, uid_hash), + CONSTRAINT fk_rfid_tags_tenant FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE, + CONSTRAINT fk_rfid_tags_member FOREIGN KEY (member_id) REFERENCES members (id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS rfid_events ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + device_id BIGINT UNSIGNED NULL, + external_event_id VARCHAR(120) NULL, + uid_hash CHAR(64) NOT NULL, + payload_json JSON NULL, + status ENUM('received', 'linked', 'ignored', 'error') NOT NULL DEFAULT 'received', + received_at DATETIME NOT NULL, + processed_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uniq_rfid_external_event (tenant_id, external_event_id), + CONSTRAINT fk_rfid_events_tenant FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE, + CONSTRAINT fk_rfid_events_device FOREIGN KEY (device_id) REFERENCES rfid_devices (id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS audit_events ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NULL, + actor_user_id BIGINT UNSIGNED NULL, + action VARCHAR(120) NOT NULL, + target_type VARCHAR(80) NOT NULL, + target_id BIGINT UNSIGNED NULL, + result ENUM('success', 'failure') NOT NULL, + request_id CHAR(36) NOT NULL, + ip_hash CHAR(64) NULL, + user_agent_hash CHAR(64) NULL, + meta_json JSON NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_audit_tenant_created (tenant_id, created_at), + CONSTRAINT fk_audit_events_tenant FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE SET NULL, + CONSTRAINT fk_audit_events_user FOREIGN KEY (actor_user_id) REFERENCES users (id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/docs/marketing-ops.md b/docs/marketing-ops.md new file mode 100644 index 0000000..8deebbf --- /dev/null +++ b/docs/marketing-ops.md @@ -0,0 +1,51 @@ +# Marketing-Operations + +## Ziel + +Der Produktauftritt soll nicht nur schoen aussehen, sondern einen messbaren +Pfad von `Interesse` zu `aktivem Tenant` schaffen. + +## Funnel-Stufen + +1. `Landing Visit` +2. `CTA Klick auf Registrierung` +3. `Tenant registriert` +4. `Erster Login` +5. `Erste Buchung` +6. `Erste Papierliste oder erstes RFID-Setup` + +## Tracking-Ereignisse + +- `landing_view` +- `pricing_view` +- `register_started` +- `tenant_registered` +- `tenant_login_success` +- `first_consumption_recorded` +- `paper_sheet_created` +- `rfid_device_created` + +## Tooling-Empfehlung + +- datenschutzfreundliches Web-Analytics statt schwerem Adtech-Setup +- Formular-Events serverseitig oder ueber schlankes Frontend-Tracking mitschreiben +- CRM-Anbindung zunaechst ueber CSV-Export oder Webhook-Fassade +- spaeter: automatisch segmentierte Onboarding-Mails pro Paket + +## Operative Fragen fuer den Go-Live + +- Welche CTA-Version konvertiert fuer Vereine besser: `Digitale Strichliste` oder + `Kaffeekasse digitalisieren`? +- Wie viele registrierte Tenants legen binnen 24 Stunden Mitglieder und Produkte + an? +- Wo brechen Interessenten ab: auf der Landingpage, im Signup oder erst beim + Login? + +## Direkt im Produkt nutzbar + +Die Landingpage im Repo ist bereits auf diese Funnel-Logik abgestimmt: + +- klares Problem-Narrativ +- Paketdarstellung +- direkter CTA zur Registrierung +- technischer Vertrauensaufbau ueber Netcup-, Papier- und RFID-Kompatibilitaet diff --git a/docs/product-strategy.md b/docs/product-strategy.md new file mode 100644 index 0000000..e7bd942 --- /dev/null +++ b/docs/product-strategy.md @@ -0,0 +1,48 @@ +# Produkt- und Vermarktungsstrategie + +## Zielgruppen + +- kleine und mittlere Teams mit gemeinsamer Kaffeekasse +- Vereine mit klassischer Strichliste +- Coworking- und Mehrraum-Standorte mit spaeterer RFID-Option + +## Kernbotschaft + +`Eine Kaffeekasse fuer analog und digital.` +Die Anwendung ersetzt Papier nicht abrupt, sondern verbindet Papierliste, +digitale Selbstbedienung und spaetere Geraeteanbindung in einem nachvollziehbaren +Buchungssystem. + +## Positionierung + +- weniger Tool-Fragmentierung als Eigenbau aus Excel, Zetteln und Einzelapps +- verstaendlichere Einfuehrung als reine Hardware- oder RFID-Produkte +- realistischer Betrieb auf klassischem Webhosting statt VPS-Pflicht + +## Paketlogik + +- `Starter`: digitale Buchungen, Mitglieder, Produkte, Einzahlungen +- `Team`: zusaetzlich Papierlisten-Workflow und staerkeres Backoffice +- `Business`: RFID-Roadmap, Betriebshooks, White-Label-/Integrationsvorstufe + +## Marketing-Funnel + +1. Landingpage erklaert den analogen und digitalen Use Case in einem Satz. +2. CTA fuehrt direkt in Mandanten-Registrierung oder Demo-Setup. +3. Der Self-Service legt sofort einen nutzbaren Tenant mit Default-Produkten an. +4. Plattform-Admin kann spaeter Vertrieb, Support und Paketwechsel steuern. + +## Messaging-Elemente + +- `Digitale Strichliste fuer Teams und Vereine` +- `Papierliste bleibt moeglich` +- `RFID spaeter anschliessen, ohne neu zu bauen` +- `Nachvollziehbare Salden und Buchungsverlaeufe` + +## Vertriebsnahe KPIs + +- Registrierungen pro Paket +- Anteil aktivierter Tenants +- erste Buchung innerhalb von 24 Stunden +- Anteil Tenants mit aktiver Papierlisten-Nutzung +- Nachfrage nach RFID-/Standortintegration diff --git a/docs/production-blueprint.md b/docs/production-blueprint.md new file mode 100644 index 0000000..bd2acda --- /dev/null +++ b/docs/production-blueprint.md @@ -0,0 +1,37 @@ +# Produktions-Blueprint + +## Architektur + +- modularer Monolith in PHP +- gemeinsame MySQL-Datenbank mit `tenant_id` +- Frontcontroller unter `public/index.php` +- Services fuer Setup, Registrierung, Ledger und RFID + +## Sicherheitsgrundlagen + +- serverseitige PHP-Sessions +- CSRF-Schutz fuer alle schreibenden Formulare +- `password_hash()` mit moderner Hash-Funktion +- Audit-Log fuer Login, Registrierung, Buchungen und RFID-Ereignisse +- path-basierte Tenant-Isolation ueber Memberships + +## Betriebsmodell + +- Installation per Browser oder `bin/migrate.php` +- Cron-basierte technische Haken ueber `bin/cron.php` +- Healthcheck als JSON ueber `bin/healthcheck.php` +- Release-Pakete ueber `scripts/build-release.sh` + +## Was vor echtem Go-Live noch folgen sollte + +- Mail-Verifikation und Passwort-Reset-Flow +- MFA fuer Admin- und Owner-Rollen +- Login- und Registrierungs-Rate-Limits +- strukturiertes Logging in Datei oder externem Ziel +- abgesicherter Support-Zugriff fuer Plattform-Admins + +## Git- und CI-Gedanke + +Das Repo ist absichtlich neutral gehalten, damit spaeter auf `git.ctb-it.de` +entweder GitLab- oder Gitea-CI angedockt werden kann. Die eigentliche +Deploy-Logik steckt in Skripten und Doku, nicht in einem proprietaeren CI-Format. diff --git a/docs/rfid-roadmap.md b/docs/rfid-roadmap.md new file mode 100644 index 0000000..a212ad6 --- /dev/null +++ b/docs/rfid-roadmap.md @@ -0,0 +1,28 @@ +# RFID-Roadmap + +## Heute schon vorhanden + +- `rfid_devices` fuer Standortgeraete +- `rfid_tags` fuer Karten/Chips pro Mitglied +- `rfid_events` als technische Inbox +- `/api/rfid/intake` fuer rohe Events + +## Warum das sinnvoll ist + +Die spaetere Hardware kann kommen, ohne dass sich das Kernmodell fuer Salden, +Konsum und Einzahlungen aendert. RFID ist nur ein weiterer Erfassungsweg in +dieselbe Buchungslogik hinein. + +## Nächste Ausbaustufen + +1. Geraet aktivieren und Token-Rotation einfuehren +2. Dublettenschutz ueber `event_id` und Zeitfenster haerten +3. Mapping-Regeln `Tag -> Mitglied -> Standardprodukt` +4. optionale Freigabe- oder Double-Tap-Regeln +5. Uebernahme von `rfid_events` in echte `consumption_events` + +## Produktionshinweis + +Wenn spaeter mehr als HTTPS-Webhooks noetig werden, sollte ein kleiner externer +Relay-Service zwischen Lesegerät und Webspace geschaltet werden. Das eigentliche +SaaS-Ledger kann trotzdem auf Netcup-Webspace bleiben. diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..56ac07e --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,7 @@ +RewriteEngine On + +RewriteCond %{REQUEST_FILENAME} -f [OR] +RewriteCond %{REQUEST_FILENAME} -d +RewriteRule ^ - [L] + +RewriteRule ^ index.php [QSA,L] diff --git a/public/assets/app.css b/public/assets/app.css new file mode 100644 index 0000000..0d92b9a --- /dev/null +++ b/public/assets/app.css @@ -0,0 +1,434 @@ +:root { + --bg: #f6efe6; + --bg-accent: #efe0cd; + --surface: rgba(255, 252, 248, 0.92); + --surface-strong: #fffaf2; + --text: #1f140f; + --muted: #6b544a; + --border: rgba(86, 48, 26, 0.14); + --primary: #8f4518; + --primary-strong: #6f2f0e; + --secondary: #124f47; + --success: #e7f7ef; + --danger: #ffe3df; + --shadow: 0 20px 60px rgba(86, 48, 26, 0.12); + --radius: 24px; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + color: var(--text); + font-family: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", Georgia, serif; + background: + radial-gradient(circle at top left, rgba(143, 69, 24, 0.18), transparent 26rem), + radial-gradient(circle at top right, rgba(18, 79, 71, 0.14), transparent 24rem), + linear-gradient(160deg, var(--bg) 0%, #fcf7f0 48%, var(--bg-accent) 100%); +} + +.page-backdrop { + position: fixed; + inset: 0; + pointer-events: none; + background: + linear-gradient(90deg, rgba(255, 255, 255, 0.05) 1px, transparent 1px), + linear-gradient(rgba(255, 255, 255, 0.05) 1px, transparent 1px); + background-size: 3rem 3rem; + mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.4), transparent 70%); +} + +.shell { + width: min(1180px, calc(100% - 2rem)); + margin: 0 auto; +} + +.site-header { + position: sticky; + top: 0; + z-index: 20; + backdrop-filter: blur(18px); + background: rgba(246, 239, 230, 0.72); + border-bottom: 1px solid var(--border); +} + +.header-inner, +.footer-grid, +.button-row, +.metric-row, +.top-nav, +.inline-head, +.two-column, +.grid-three, +.content-grid, +.plan-grid, +.metric-cards { + display: grid; + gap: 1rem; +} + +.header-inner { + grid-template-columns: auto 1fr; + align-items: center; + padding: 1rem 0; +} + +.brand { + display: inline-flex; + align-items: center; + gap: 0.85rem; + color: inherit; + text-decoration: none; +} + +.brand strong, +.hero h1, +.page-head h1, +.section-head h2 { + letter-spacing: -0.03em; +} + +.brand small, +.muted, +.stack-list small, +.feature-list, +.metric-row span, +.top-nav a, +.site-footer p { + color: var(--muted); +} + +.brand-mark { + display: inline-grid; + place-items: center; + width: 2.75rem; + height: 2.75rem; + border-radius: 0.9rem; + background: linear-gradient(135deg, var(--primary), #c07a3d); + color: white; + font-weight: 700; +} + +.top-nav { + grid-auto-flow: column; + justify-content: end; + align-items: center; +} + +.top-nav a { + text-decoration: none; + padding: 0.65rem 0.9rem; + border-radius: 999px; +} + +.top-nav a.active, +.top-nav a:hover { + background: rgba(18, 79, 71, 0.08); + color: var(--secondary); +} + +.main-shell { + padding: 2rem 0 4rem; +} + +.hero, +.two-column, +.content-grid, +.plan-grid, +.metric-cards { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.hero { + align-items: stretch; + margin-bottom: 2rem; +} + +.hero-copy, +.hero-card, +.card, +.form-card, +.table-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); +} + +.hero-copy { + padding: 2.4rem; +} + +.hero-card { + padding: 2rem; + align-self: stretch; + background: + linear-gradient(180deg, rgba(255,255,255,0.84), rgba(255,250,242,0.94)), + linear-gradient(135deg, rgba(18,79,71,0.12), transparent 50%); +} + +.hero h1, +.page-head h1 { + font-size: clamp(2.2rem, 4vw, 4.6rem); + line-height: 0.96; + margin: 0.6rem 0 1rem; +} + +.lead { + font-size: 1.15rem; + line-height: 1.7; + max-width: 56rem; +} + +.button-row, +.metric-row { + grid-auto-flow: column; + justify-content: start; +} + +.button { + display: inline-flex; + justify-content: center; + align-items: center; + padding: 0.95rem 1.25rem; + border-radius: 999px; + border: 1px solid transparent; + text-decoration: none; + font-weight: 700; + cursor: pointer; +} + +.button-primary { + color: white; + background: linear-gradient(135deg, var(--primary), var(--primary-strong)); +} + +.button-secondary { + color: var(--secondary); + border-color: rgba(18, 79, 71, 0.24); + background: rgba(18, 79, 71, 0.08); +} + +.metric-row { + list-style: none; + padding: 0; + margin: 1.4rem 0 0; + align-items: end; +} + +.metric-row strong { + display: block; + font-size: 1.7rem; +} + +.eyebrow { + display: inline-block; + text-transform: uppercase; + letter-spacing: 0.18em; + font-size: 0.72rem; + color: var(--primary); + font-weight: 700; +} + +.feature-list, +.stack-list, +.step-list { + margin: 1rem 0 0; + padding-left: 1.2rem; +} + +.stats-panel, +.metric-cards, +.content-grid, +.plan-grid, +.grid-three { + margin-top: 1.5rem; +} + +.stats-panel { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.85rem; +} + +.stats-panel div, +.metric-card { + padding: 1rem; + border-radius: 1rem; + background: rgba(255,255,255,0.74); + border: 1px solid var(--border); +} + +.metric-card strong, +.stats-panel strong { + display: block; + font-size: 1.7rem; +} + +.content-grid, +.plan-grid, +.grid-three { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.card, +.form-card, +.table-card, +.timeline { + padding: 1.4rem; +} + +.highlighted { + border-color: rgba(143, 69, 24, 0.38); + background: linear-gradient(180deg, rgba(255, 251, 244, 0.98), rgba(250, 236, 219, 0.9)); +} + +.page-head { + margin-bottom: 1.5rem; +} + +.inline-head { + grid-template-columns: 1fr auto; + align-items: center; +} + +.form-card, +.table-card { + overflow: hidden; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; +} + +.slim { + max-width: 34rem; +} + +label { + display: grid; + gap: 0.45rem; + margin-bottom: 1rem; + font-weight: 700; +} + +input, +select, +textarea, +button { + font: inherit; +} + +input, +select, +textarea { + width: 100%; + padding: 0.85rem 1rem; + border-radius: 1rem; + border: 1px solid rgba(86, 48, 26, 0.18); + background: rgba(255,255,255,0.95); +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, +td { + padding: 0.95rem 0.8rem; + border-bottom: 1px solid rgba(86, 48, 26, 0.08); + text-align: left; + vertical-align: top; +} + +th { + font-size: 0.82rem; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--muted); +} + +.stack-list { + list-style: none; + padding: 0; +} + +.stack-list li { + display: grid; + gap: 0.2rem; + padding: 0.85rem 0; + border-bottom: 1px solid rgba(86, 48, 26, 0.08); +} + +.flash { + margin-bottom: 1rem; + padding: 1rem 1.2rem; + border-radius: 1rem; + font-weight: 700; +} + +.flash-success { + background: var(--success); + border: 1px solid rgba(18, 79, 71, 0.16); +} + +.flash-error { + background: var(--danger); + border: 1px solid rgba(143, 69, 24, 0.16); +} + +.code-block { + overflow: auto; + padding: 1rem; + border-radius: 1rem; + background: #201814; + color: #fef0df; +} + +.text-link { + color: var(--secondary); + text-decoration: none; +} + +.site-footer { + padding: 0 0 2rem; +} + +.footer-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + padding: 1.5rem; + background: rgba(255,255,255,0.64); + border: 1px solid var(--border); + border-radius: var(--radius); +} + +@media (max-width: 960px) { + .hero, + .two-column, + .content-grid, + .plan-grid, + .metric-cards, + .grid-three, + .footer-grid, + .form-grid, + .inline-head { + grid-template-columns: 1fr; + } + + .top-nav, + .button-row, + .metric-row { + grid-auto-flow: row; + justify-content: start; + } + + .header-inner { + grid-template-columns: 1fr; + } +} diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..0aee741 --- /dev/null +++ b/public/index.php @@ -0,0 +1,123 @@ +home(); + return; + } + + if ($path === '/install') { + $method === 'POST' ? $platform->installSubmit() : $platform->installForm(); + return; + } + + if ($path === '/register') { + $method === 'POST' ? $platform->registerSubmit() : $platform->registerForm(); + return; + } + + if ($path === '/admin/login') { + $method === 'POST' ? $platform->adminLoginSubmit() : $platform->adminLoginForm(); + return; + } + + if ($path === '/admin' && $method === 'GET') { + $platform->adminDashboard(); + return; + } + + if ($path === '/admin/logout' && $method === 'POST') { + $platform->adminLogout(); + return; + } + + if ($path === '/api/rfid/intake' && $method === 'POST') { + $tenantController->rfidIngest(); + return; + } + + if (preg_match('#^/t/([^/]+)(?:/(.*))?$#', $path, $matches)) { + $tenantSlug = rawurldecode($matches[1]); + $subPath = trim((string) ($matches[2] ?? ''), '/'); + + if ($subPath === '' && $method === 'GET') { + $tenantController->dashboard($tenantSlug); + return; + } + + if ($subPath === 'login') { + $method === 'POST' ? $tenantController->loginSubmit($tenantSlug) : $tenantController->loginForm($tenantSlug); + return; + } + + if ($subPath === 'logout' && $method === 'POST') { + $tenantController->logout($tenantSlug); + return; + } + + if ($subPath === 'members') { + $tenantController->members($tenantSlug); + return; + } + + if ($subPath === 'products') { + $tenantController->products($tenantSlug); + return; + } + + if ($subPath === 'bookings') { + $tenantController->bookings($tenantSlug); + return; + } + + if ($subPath === 'payments') { + $tenantController->payments($tenantSlug); + return; + } + + if ($subPath === 'paper-sheets') { + $tenantController->paperSheets($tenantSlug); + return; + } + + if ($subPath === 'rfid') { + $tenantController->rfid($tenantSlug); + return; + } + } + + Response::notFound(); +} catch (\Throwable $throwable) { + http_response_code(500); + error_log($throwable->__toString()); + + echo '

Fehler

'; + echo '

' . e($app['config']['debug'] ? $throwable->getMessage() : 'Die Anfrage konnte gerade nicht verarbeitet werden.') . '

'; +} diff --git a/public/router.php b/public/router.php new file mode 100644 index 0000000..c7f1245 --- /dev/null +++ b/public/router.php @@ -0,0 +1,12 @@ + + +
+
+ Betrieb +

Plattform-Dashboard

+

Ueberblick ueber Mandanten, Paketstand und direkte Einstiege in die Kundenumgebungen.

+
+
+ + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
MandantPaketStatusMitgliederProdukteAktion
+
+ /t/ +
Tenant oeffnen
+
+ + diff --git a/resources/views/admin/login.php b/resources/views/admin/login.php new file mode 100644 index 0000000..c4311c8 --- /dev/null +++ b/resources/views/admin/login.php @@ -0,0 +1,22 @@ + + +
+ Plattform +

Admin-Login

+

Fuer Betrieb, Mandantenuebersicht, Support und spaetere Vermarktungssteuerung.

+
+ +
+ + + + +
+ + diff --git a/resources/views/home/index.php b/resources/views/home/index.php new file mode 100644 index 0000000..615f75e --- /dev/null +++ b/resources/views/home/index.php @@ -0,0 +1,108 @@ + + +
+
+ Von der Papierliste zur produktiven SaaS +

Kaffeeliste, digitale Strichliste und RFID-Roadmap in einem System.

+

Die Anwendung verbindet klassische Kaffeekasse, digitale Selbstbuchung und spaetere Geraeteintegration. Mandanten registrieren sich selbst, verwalten Mitglieder, Produkte, Einzahlungen und Papierlisten in einem PHP/MySQL-Stack, der auf Netcup-Webspace laeuft.

+ +
    +
  • 1gemeinsames Ledger fuer digital, Papier und RFID
  • +
  • 3Pakete fuer Vermarktung und Rollout
  • +
  • 100%Webspace-kompatibler PHP/MySQL-Betrieb
  • +
+
+ +
+ +
+
+ Produkt +

Eine Buchungslogik, mehrere Erfassungswege

+

Jede Kaffee- oder Tee-Buchung landet im selben Ledger. Ob jemand digital klickt, ob ein Admin eine Papierliste erfasst oder ob spaeter ein RFID-Leser Events einspeist: Salden, Historie und Auswertungen bleiben konsistent.

+
+
+ Marketing +

Klare Positionierung fuer Teams, Vereine und Coworking

+

Die Startseite spricht gezielt Organisationen an, die zwischen analoger Kaffeekasse und digitaler Selbstbedienung stehen. Das senkt Erklaerungsaufwand und macht den Upgrade-Pfad zu RFID nachvollziehbar.

+
+
+ Betrieb +

Netcup-tauglich statt hyperskalierter Overkill

+

Pfadbasierte Mandanten, gemeinsamer MySQL-Kern, Cron statt Worker und ein sauberer Webroot auf `public/` sind genau auf klassisches Webhosting zugeschnitten.

+
+
+ +
+
+ Pakete +

Preismodelle fuer die Vermarktung

+
+
+
+

Starter

+

Fuer kleine Teams und Vereine mit digitaler Buchung und einfacher Kaffeekasse.

+
    +
  • Mitglieder, Produkte, digitale Buchungen
  • +
  • Einzahlungen und Salden
  • +
  • Self-Service-Registrierung
  • +
+
+
+

Team

+

Fuer Organisationen, die Papierlisten und digitale Prozesse parallel fahren.

+
    +
  • Papierlisten-Workflow mit Nacherfassung
  • +
  • Plattform-Dashboard und Audit-Trail
  • +
  • Vorbereitung fuer Standortgeraete
  • +
+
+
+

Business

+

Fuer Standorte mit RFID-Roadmap, Betriebsanforderungen und spaeterem White-Labeling.

+
    +
  • RFID-Devices, Karten und Intake-API
  • +
  • Release-, Backup- und Deployment-Rahmen
  • +
  • Vorlage fuer spaetere Integrationen
  • +
+
+
+
+ +
+
+ Roadmap +

Sauberer Pfad in die Produktion

+
+
    +
  1. Plattform installieren und ersten Admin anlegen.
  2. +
  3. Mandant registrieren, Owner-Onboarding abschliessen und Produkte vorbelegen.
  4. +
  5. Digitale Buchungen und Papierlisten parallel im Team einsetzen.
  6. +
  7. Mit RFID-Geraeten spaeter ueber die Event-Inbox anbinden, ohne das Kernledger umzubauen.
  8. +
+
+ + diff --git a/resources/views/home/register.php b/resources/views/home/register.php new file mode 100644 index 0000000..0309560 --- /dev/null +++ b/resources/views/home/register.php @@ -0,0 +1,59 @@ + + +
+ Self-Service +

Mandant registrieren

+

Der Owner legt den ersten Standort an und bekommt automatisch die Grundbausteine fuer digitale Buchung, Papierliste und spaetere RFID-Anbindung.

+
+ +
+
+ + + + + + + + + +
+ + +
+ + diff --git a/resources/views/install/index.php b/resources/views/install/index.php new file mode 100644 index 0000000..51e8a9c --- /dev/null +++ b/resources/views/install/index.php @@ -0,0 +1,56 @@ + + +
+ Setup +

Plattform installieren

+

Der Installer schreibt `.env`, legt das MySQL-Schema an und erstellt den ersten Plattform-Admin. Danach ist das Self-Service-Onboarding fuer Mandanten freigeschaltet.

+
+ +
+ +
+ + + + + + + + + + +
+ +
+ + diff --git a/resources/views/partials/layout-bottom.php b/resources/views/partials/layout-bottom.php new file mode 100644 index 0000000..c9451cc --- /dev/null +++ b/resources/views/partials/layout-bottom.php @@ -0,0 +1,16 @@ + + + + + diff --git a/resources/views/partials/layout-top.php b/resources/views/partials/layout-top.php new file mode 100644 index 0000000..2e5cc75 --- /dev/null +++ b/resources/views/partials/layout-top.php @@ -0,0 +1,50 @@ +get('auth'); +$tenant = $tenant ?? null; +$currentPath = current_path(); +?> + + + + + + <?= e($title ?? $config['name']) ?> + + + +
+ +
+ +
+ + +
+ diff --git a/resources/views/tenant/bookings.php b/resources/views/tenant/bookings.php new file mode 100644 index 0000000..a2f8908 --- /dev/null +++ b/resources/views/tenant/bookings.php @@ -0,0 +1,76 @@ + + +
+ Selbstbedienung +

Digitale Buchungen

+

Dies ist der produktive Kern fuer Browser, Tablet und spaetere Kiosk-Workflows.

+
+ +
+
+ + + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + +
ZeitMitgliedProduktQuelleBetrag
+
+
+ + diff --git a/resources/views/tenant/dashboard.php b/resources/views/tenant/dashboard.php new file mode 100644 index 0000000..a0b0f79 --- /dev/null +++ b/resources/views/tenant/dashboard.php @@ -0,0 +1,60 @@ + + +
+
+ Paket +

+

Rolle: . Hier laufen digitale Buchungen, Papierlisten und Betriebszahlen zusammen.

+
+
+ + +
+
+ +
+
+ Mitglieder + +
+
+ Produkte + +
+
+ Papierlisten + +
+
+ Offene Salden + +
+
+ +
+
+ Letzte Buchungen +
    + +
  • + + via + am +
  • + +
+
+
+ Hohe Salden +
    + +
  • + + +
  • + +
+
+
+ + diff --git a/resources/views/tenant/login.php b/resources/views/tenant/login.php new file mode 100644 index 0000000..57a8a4a --- /dev/null +++ b/resources/views/tenant/login.php @@ -0,0 +1,22 @@ + + +
+ Mandant +

+

Digitale Strichliste, Papierlisten-Nacherfassung und spaetere RFID-Anbindung fuer diesen Standort.

+
+ +
+ + + + +
+ + diff --git a/resources/views/tenant/members.php b/resources/views/tenant/members.php new file mode 100644 index 0000000..b969521 --- /dev/null +++ b/resources/views/tenant/members.php @@ -0,0 +1,55 @@ + + +
+ Stammdaten +

Mitglieder verwalten

+

Mitglieder koennen rein fachlich gefuehrt werden oder optional einen Login fuer die digitale Buchung erhalten.

+
+ +
+
+ + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + +
NameE-MailPINStatus
+
+
+ + diff --git a/resources/views/tenant/paper-sheets.php b/resources/views/tenant/paper-sheets.php new file mode 100644 index 0000000..4e9ec47 --- /dev/null +++ b/resources/views/tenant/paper-sheets.php @@ -0,0 +1,143 @@ + + +
+ Klassische Liste +

Papierlisten und Nacherfassung

+

Die klassische Kaffeeliste bleibt erhalten, wird aber als geordneter Beleg mit sauberem Posting ins Ledger behandelt.

+
+ +
+
+ + + + + + + + +
+ +
+ + + + + + + + + +
+ +
+ Posting +

Wenn ein Entwurf vollstaendig ist, wird er mit einem Klick in dieselbe Ledger-Logik uebernommen wie digitale Buchungen.

+ + +
+ + + + +
+ + +
+
+ +
+
+ + + + + + + + + + + + + + + + + + + +
TitelStatusZeilenZeitraum
+
+
+ + + + + + + + + + + + + + + + + + + +
PapierlisteMitgliedProduktMenge
#
+
+
+ + diff --git a/resources/views/tenant/payments.php b/resources/views/tenant/payments.php new file mode 100644 index 0000000..4cda6da --- /dev/null +++ b/resources/views/tenant/payments.php @@ -0,0 +1,59 @@ + + +
+ Finanzen +

Einzahlungen verbuchen

+

Einzahlungen laufen ins selbe Ledger und reduzieren den offenen Saldo des Mitglieds nachvollziehbar.

+
+ +
+
+ + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + +
ZeitMitgliedBeschreibungBetrag
+
+
+ + diff --git a/resources/views/tenant/products.php b/resources/views/tenant/products.php new file mode 100644 index 0000000..43e12e5 --- /dev/null +++ b/resources/views/tenant/products.php @@ -0,0 +1,47 @@ + + +
+ Sortiment +

Produkte und Preise

+

Die Preislogik bleibt versionierbar. Jede Buchung friert den zum Zeitpunkt gueltigen Preis ein.

+
+ +
+
+ + + + +
+ +
+ + + + + + + + + + + + + + + + + + + +
ProduktSKUPreisStatus
+
+
+ + diff --git a/resources/views/tenant/rfid.php b/resources/views/tenant/rfid.php new file mode 100644 index 0000000..5009be5 --- /dev/null +++ b/resources/views/tenant/rfid.php @@ -0,0 +1,129 @@ + + +
+ Integration +

RFID-Roadmap und Event-Inbox

+

Das MVP speichert Geraete, Karten und rohe Events bereits produktionsnah, ohne die spaetere Hardwareentscheidung vorwegzunehmen.

+
+ +
+
+ + + + + +
+ +
+ + + + + + +
+ +
+ API +

POST `/api/rfid/intake` mit JSON-Body:

+
{
+  "device_token": "...",
+  "uid": "04AABBCCDD",
+  "event_id": "reader-0001",
+  "ts": "2026-06-15T09:30:00Z"
+}
+

Events werden tenant-scharf gespeichert und spaeter in Buchungsregeln ueberfuehrt.

+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + +
GeraetStandortStatusToken
...
+
+
+ + + + + + + + + + + + + + + + + + + +
KarteMitgliedStatusZuletzt
+
+
+ +
+ + + + + + + + + + + + + + + + + + + +
ZeitGeraetStatusEvent-ID
+
+ + diff --git a/scripts/build-release.sh b/scripts/build-release.sh new file mode 100755 index 0000000..0b6b905 --- /dev/null +++ b/scripts/build-release.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BUILD_DIR="$ROOT_DIR/build" +STAMP="$(date +%Y%m%d-%H%M%S)" +TARGET="$BUILD_DIR/kaffeekasse-saas-$STAMP.tar.gz" + +mkdir -p "$BUILD_DIR" + +tar \ + --exclude=".git" \ + --exclude=".env" \ + --exclude="build" \ + --exclude="storage/cache/*" \ + --exclude="storage/logs/*" \ + --exclude="storage/uploads/*" \ + -czf "$TARGET" \ + -C "$ROOT_DIR" . + +echo "Release package created: $TARGET" diff --git a/scripts/deploy-ftpes.sh b/scripts/deploy-ftpes.sh new file mode 100755 index 0000000..81099b1 --- /dev/null +++ b/scripts/deploy-ftpes.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +cat <<'EOF' +Dieses Repo ist fuer Netcup-Deploys per FTPES/SFTP vorbereitet. + +Empfohlener Ablauf: +1. build-release.sh lokal oder in CI ausfuehren +2. Release nach /apps/kaffeekasse/current hochladen +3. Document Root auf /apps/kaffeekasse/current/public setzen +4. Anschliessend per Scheduled Task oder Shell starten: + /usr/local/php83/bin/php /apps/kaffeekasse/current/bin/migrate.php + +Das eigentliche Upload-Kommando haengt von eurem CI-System und den Zugangsdaten ab +und bleibt deshalb bewusst ausserhalb des Repos. +EOF diff --git a/storage/cron.lock b/storage/cron.lock new file mode 100644 index 0000000..6febc23 --- /dev/null +++ b/storage/cron.lock @@ -0,0 +1 @@ +1781536386 \ No newline at end of file