diff --git a/README.md b/README.md
index cc1d596..830b702 100644
--- a/README.md
+++ b/README.md
@@ -36,6 +36,17 @@ Nach dem Setup gibt es jetzt zwei zentrale Web-Einstiege:
- `saas-app/public/admin/login/` fuer den Global-Admin
- `saas-app/public/admin/` fuer die zentrale Verwaltung
+- `saas-app/public/admin/updates/` fuer den webbasierten Update-Prozess
+
+## SaaS-Betrieb
+
+Die aktuelle Ausbaustufe enthaelt bereits die wichtigsten SaaS-Bausteine:
+
+- Lizenzplaene pro Mandant mit Funktionsfreischaltung
+- Mandanten-Einstellungen fuer Preise, PDF-Listen und Kommunikation
+- Drucklisten als PDF-Ansicht plus Nacherfassung von Vorder- und Rueckseite
+- Basis-Exporte fuer Mitglieder und Ledger als CSV
+- Global-Admin-Zugriff zum direkten Oeffnen eines Mandanten
## Hilfsskripte
@@ -44,6 +55,7 @@ Nach dem Setup gibt es jetzt zwei zentrale Web-Einstiege:
- `scripts/install-saas.php` fuehrt den lokalen Setup-Grundlauf aus.
- `scripts/build-migration-bundle.php` baut die SQL-Migrationen zu einer Datei.
- `scripts/run-sql-migrations.php` fuehrt die SQL-Migrationen direkt per PDO fuer den konfigurierten DB-Treiber aus.
+- `scripts/run-updates.php` fuehrt nachtraegliche System-Updates per PHP aus.
## Hinweise Zum Umbau
diff --git a/docs/installationshandbuch.md b/docs/installationshandbuch.md
index 6b7f941..2dddd48 100644
--- a/docs/installationshandbuch.md
+++ b/docs/installationshandbuch.md
@@ -16,6 +16,7 @@ Der Installer kann:
- die `.env` speichern
- das SQL-Bundle erzeugen
- Migrationen direkt per PHP ausfuehren, wenn die zum Treiber passende PDO-Erweiterung verfuegbar ist
+- nachtraegliche System-Updates automatisch einspielen
- den ersten Global-Admin direkt anlegen
- sich nach erfolgreicher Einrichtung sperren
@@ -93,7 +94,7 @@ eigene Umgebung angepasst werden:
4. Sicherstellen, dass `open_basedir` nicht nur auf `public/` eingeschraenkt ist. PHP muss mindestens auf den kompletten Ordner `saas-app/` zugreifen duerfen, obwohl der Document-Root auf `public/` zeigt.
5. Den Installer unter `/install/` aufrufen und die Einrichtung durchfuehren.
6. Danach den Global-Admin unter `/admin/login` anmelden.
-7. In der zentralen Verwaltung unter `/admin/` die ersten Tenants anlegen.
+7. In der zentralen Verwaltung unter `/admin/tenants` die ersten Tenants inklusive Lizenzplan anlegen.
8. Nach erfolgreicher Einrichtung den Installer sperren.
9. Die Anwendung einmal per Browser aufrufen und die Grundseiten pruefen.
@@ -131,6 +132,35 @@ Nach der Installation stehen diese Web-Einstiege bereit:
- `/admin/login` fuer den Global-Admin
- `/admin/` fuer die zentrale Verwaltungsuebersicht
- `/admin/tenants` fuer die Tenant-Verwaltung
+- `/admin/updates` fuer System-Updates und Datenbankerweiterungen
+
+## Lizenzplaene
+
+Die SaaS-Anwendung arbeitet jetzt mit Lizenzplaenen pro Mandant. Darueber kann
+gesteuert werden, welche Zusatzfunktionen im jeweiligen Tenant verfuegbar sind.
+
+- `Starter`: Basisfunktionen fuer Mitglieder, Buchungen, Einzahlungen und Inhalte
+- `Team`: zusaetzlich Mandanten-Einstellungen, PDF-Drucklisten, Papierlisten-Erfassung und Basis-Exporte
+- `Business`: vorbereitet fuer SSO, Importe, Exporte, Benachrichtigungen und Auswertungen
+- `Enterprise`: erweitert um White-Labeling, Sonderfunktionen und priorisierte Updates
+
+Mandanten-Einstellungen und Exportfunktionen muessen daher nicht fuer jede
+Lizenz freigeschaltet sein.
+
+## Update-Prozess
+
+Schemaaenderungen und spaetere Datenanpassungen laufen ueber versionierte
+PHP-Update-Dateien unter `saas-app/updates/`.
+
+Der bevorzugte Weg auf Webspace ist:
+
+1. Als Global-Admin anmelden.
+2. `/admin/updates` aufrufen.
+3. Ausstehende Updates per Klick ausfuehren.
+
+Alternativ steht lokal oder auf Servern mit Shell-Zugriff weiter zur Verfuegung:
+
+- `php scripts/run-updates.php`
## Betriebscheck
diff --git a/saas-app/README.md b/saas-app/README.md
index 418da8e..def69f2 100644
--- a/saas-app/README.md
+++ b/saas-app/README.md
@@ -52,6 +52,30 @@ Nach der Installation erfolgt die zentrale Administration ueber:
- `public/admin/login/`
- `public/admin/`
- `public/admin/tenants/`
+- `public/admin/updates/`
+
+## Lizenzen und Mandantenbetrieb
+
+Mandanten koennen jetzt direkt mit einem Lizenzplan angelegt werden. Der
+Lizenzplan steuert, welche Bereiche im Tenant sichtbar und nutzbar sind.
+
+- `Starter`: Basisfunktionen fuer Mitglieder, Ledger, Einzahlungen und Inhalte
+- `Team`: zusaetzlich Mandanten-Einstellungen, PDF-Listen, Papierlisten-Erfassung und Basis-Exporte
+- `Business`: vorbereitet fuer SSO, Importe, Exporte, Benachrichtigungen und Auswertungen
+- `Enterprise`: erweitert um White-Labeling, Sonderfunktionen und priorisierte Updates
+
+Der Global-Admin kann jeden Mandanten oeffnen und ihn mit erweiterten Rechten
+aus Sicht des jeweiligen Tenant-Admins pruefen.
+
+## Update-Prozess
+
+Nachtraegliche Schema- und Datenupdates laufen versioniert ueber:
+
+- `public/admin/updates/` im Browser
+- `php ../scripts/run-updates.php` per CLI
+
+Die Update-Dateien liegen in `updates/` und werden in `app_updates`
+protokolliert.
## Hosting-Hinweise
diff --git a/saas-app/public/admin.php b/saas-app/public/admin.php
index c83a010..f31e7be 100644
--- a/saas-app/public/admin.php
+++ b/saas-app/public/admin.php
@@ -22,7 +22,27 @@ function admin_badge(string $label, string $tone = 'neutral'): string
return '' . admin_h($label) . '';
}
-function admin_summary_metrics(array $tenants): array
+function admin_update_summary(array $items): array
+{
+ $summary = [
+ 'pending' => 0,
+ 'success' => 0,
+ 'failed' => 0,
+ 'running' => 0,
+ ];
+
+ foreach ($items as $item) {
+ $status = (string) ($item['status'] ?? 'pending');
+
+ if (array_key_exists($status, $summary)) {
+ $summary[$status]++;
+ }
+ }
+
+ return $summary;
+}
+
+function admin_summary_metrics(array $tenants, array $updates): array
{
$tenantCount = count($tenants);
$activeTenants = 0;
@@ -40,12 +60,15 @@ function admin_summary_metrics(array $tenants): array
$providerCount += (int) ($tenant['provider_count'] ?? 0);
}
+ $updateSummary = admin_update_summary($updates);
+
return [
['label' => 'Mandanten gesamt', 'value' => (string) $tenantCount, 'detail' => 'Mandanten im zentralen Portfolio.'],
['label' => 'Aktive Mandanten', 'value' => (string) $activeTenants, 'detail' => 'Bereiche mit aktivem Status.'],
['label' => 'Mitglieder gesamt', 'value' => (string) $memberCount, 'detail' => 'Aktive Zuordnungen über alle Mandanten.'],
- ['label' => 'SSO-Provider', 'value' => (string) $providerCount, 'detail' => 'Aktive Identitätsanbieter für die Anmeldung.'],
['label' => 'Tenant-Admins', 'value' => (string) $adminCount, 'detail' => 'Verwaltungszugänge in den Mandanten.'],
+ ['label' => 'Offene Updates', 'value' => (string) $updateSummary['pending'], 'detail' => 'Noch nicht eingespielte System-Updates.'],
+ ['label' => 'SSO-Provider', 'value' => (string) $providerCount, 'detail' => 'Aktive Identitätsanbieter für die Anmeldung.'],
];
}
@@ -55,6 +78,7 @@ $page = match ($path) {
'/admin', '/admin/' => 'overview',
'/admin/login' => 'login',
'/admin/tenants' => 'tenants',
+ '/admin/updates' => 'updates',
'/admin/logout' => 'logout',
default => 'login',
};
@@ -67,6 +91,10 @@ $adminLogin = ['state' => app_admin_login_state(), 'message' => null, 'error' =>
$tenants = [];
$editingTenant = null;
$summaryMetrics = [];
+$licensePlans = [];
+$updateItems = [];
+$updateSummary = ['pending' => 0, 'success' => 0, 'failed' => 0, 'running' => 0];
+$appVersion = is_file(dirname(__DIR__) . '/version.php') ? (string) require dirname(__DIR__) . '/version.php' : 'unbekannt';
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST' && $page === 'logout') {
if (hash_equals($_SESSION['admin_csrf'], (string) ($_POST['csrf'] ?? ''))) {
@@ -88,7 +116,7 @@ if ($page === 'login' && $pdo instanceof PDO) {
$admin = app_admin_user();
}
-if (in_array($page, ['overview', 'tenants'], true)) {
+if (in_array($page, ['overview', 'tenants', 'updates'], true)) {
$admin = app_require_platform_admin();
if (!$pdo instanceof PDO) {
@@ -96,25 +124,69 @@ if (in_array($page, ['overview', 'tenants'], true)) {
}
}
-if (in_array($page, ['overview', 'tenants'], true) && $pdo instanceof PDO) {
+if (in_array($page, ['overview', 'tenants', 'updates'], true) && $pdo instanceof PDO) {
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST' && !hash_equals($_SESSION['admin_csrf'], (string) ($_POST['csrf'] ?? ''))) {
app_flash('Die Sitzung ist abgelaufen. Bitte lade die Seite neu.', 'error');
- app_redirect('/admin/tenants/');
+ app_redirect('/admin/' . ($page === 'overview' ? '' : $page . '/'));
+ }
+
+ if ($page === 'tenants') {
+ app_handle_platform_tenant_action($pdo);
+ }
+
+ if ($page === 'updates' && (string) ($_POST['action'] ?? '') === 'run-updates') {
+ try {
+ $results = scripts_run_pending_updates(
+ scripts_update_config_from_env(app_env()),
+ 'admin:' . (string) ($admin['email'] ?? 'platform')
+ );
+
+ $successful = count(array_filter($results, static fn(array $result): bool => ($result['status'] ?? '') === 'success'));
+ $skipped = count(array_filter($results, static fn(array $result): bool => ($result['status'] ?? '') === 'skipped'));
+
+ app_flash(
+ $successful . ' Update(s) ausgeführt, ' . $skipped . ' bereits erledigt.',
+ 'success'
+ );
+ } catch (Throwable $exception) {
+ app_flash('Die Updates konnten nicht abgeschlossen werden: ' . $exception->getMessage(), 'error');
+ }
+
+ app_redirect('/admin/updates/');
}
- app_handle_platform_tenant_action($pdo);
$tenants = app_admin_tenant_list($pdo);
- $summaryMetrics = admin_summary_metrics($tenants);
+ $licensePlans = app_license_plan_options($pdo);
+ $updateItems = scripts_list_updates_status(scripts_update_config_from_env(app_env()));
+ $updateSummary = admin_update_summary($updateItems);
+ $summaryMetrics = admin_summary_metrics($tenants, $updateItems);
if ($page === 'tenants' && isset($_GET['edit']) && $_GET['edit'] !== '') {
- $editingTenant = app_query_one(
- $pdo,
- 'SELECT id, tenant_key, name, status FROM tenants WHERE id = :id LIMIT 1',
- ['id' => (string) $_GET['edit']]
- );
+ $editingTenant = $licensePlans !== []
+ ? app_query_one(
+ $pdo,
+ <<<'SQL'
+SELECT
+ t.id,
+ t.tenant_key,
+ t.name,
+ t.status,
+ COALESCE(lp.plan_key, 'team') AS plan_key
+FROM tenants t
+LEFT JOIN tenant_licenses tl ON tl.tenant_id = t.id AND tl.status = 'active'
+LEFT JOIN license_plans lp ON lp.id = tl.license_plan_id
+WHERE t.id = :id
+LIMIT 1
+SQL,
+ ['id' => (string) $_GET['edit']]
+ )
+ : app_query_one(
+ $pdo,
+ "SELECT id, tenant_key, name, status, 'team' AS plan_key FROM tenants WHERE id = :id LIMIT 1",
+ ['id' => (string) $_GET['edit']]
+ );
}
}
-
?>
@@ -123,7 +195,7 @@ if (in_array($page, ['overview', 'tenants'], true) && $pdo instanceof PDO) {
Kaffeeliste Admin
@@ -131,13 +203,14 @@ if (in_array($page, ['overview', 'tenants'], true) && $pdo instanceof PDO) {
Kaffeeliste Admin
-
Zentrale Verwaltung für Mandanten, Zugänge und Betriebsstatus.
+
Zentrale Verwaltung für Mandanten, Lizenzen, Updates und Betriebsstatus.