M3 SaaS-Basis mit Default-Tenant vorbereiten

- Tenant/User/Membership/Participant-Tabellen additiv migrieren
- Default-Tenant-Backfill fuer Legacy-Mitarbeiter und Admins ergaenzen
- M3-Kontrollskript und Plan-Dokumentation aktualisieren
This commit is contained in:
2026-07-12 19:40:28 +02:00
parent 8511b876f3
commit eef0338e31
5 changed files with 465 additions and 22 deletions
@@ -0,0 +1,92 @@
CREATE TABLE IF NOT EXISTS tenants (
id INT AUTO_INCREMENT PRIMARY KEY,
slug VARCHAR(100) NOT NULL,
name VARCHAR(255) NOT NULL,
status VARCHAR(30) NOT NULL DEFAULT 'active',
timezone VARCHAR(100) NOT NULL DEFAULT 'Europe/Berlin',
locale VARCHAR(20) NOT NULL DEFAULT 'de-DE',
currency_code CHAR(3) NOT NULL DEFAULT 'EUR',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_tenants_slug (slug),
KEY idx_tenants_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS tenant_settings (
tenant_id INT PRIMARY KEY,
mark_price_cents INT NOT NULL DEFAULT 20,
self_entry_enabled TINYINT(1) NOT NULL DEFAULT 1,
paypal_enabled TINYINT(1) NOT NULL DEFAULT 0,
paypal_url_template VARCHAR(1000) NOT NULL DEFAULT '',
sheet_window_days INT NOT NULL DEFAULT 100,
negative_warning_cents INT NOT NULL DEFAULT -1000,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
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 users (
id INT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL,
email_norm VARCHAR(255) NOT NULL,
display_name VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NULL,
status VARCHAR(30) NOT NULL DEFAULT 'active',
email_verified_at DATETIME NULL,
mfa_enabled TINYINT(1) NOT NULL DEFAULT 0,
disabled_at DATETIME NULL,
last_login_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_users_email_norm (email_norm),
KEY idx_users_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS tenant_memberships (
id INT AUTO_INCREMENT PRIMARY KEY,
tenant_id INT NOT NULL,
user_id INT NOT NULL,
role VARCHAR(30) NOT NULL DEFAULT 'member',
status VARCHAR(30) NOT NULL DEFAULT 'active',
invited_at DATETIME NULL,
joined_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_tenant_memberships_tenant_user (tenant_id, user_id),
KEY idx_tenant_memberships_user (user_id),
KEY idx_tenant_memberships_tenant_role_status (tenant_id, role, status),
CONSTRAINT fk_tenant_memberships_tenant
FOREIGN KEY (tenant_id) REFERENCES tenants(id)
ON DELETE CASCADE,
CONSTRAINT fk_tenant_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 participants (
id INT AUTO_INCREMENT PRIMARY KEY,
tenant_id INT NOT NULL,
user_id INT NULL,
display_name VARCHAR(255) NOT NULL,
email VARCHAR(255) NULL,
email_norm VARCHAR(255) NULL,
paypal_name VARCHAR(255) NULL,
active TINYINT(1) NOT NULL DEFAULT 1,
legacy_mitarbeiter_id INT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_participants_tenant_legacy (tenant_id, legacy_mitarbeiter_id),
UNIQUE KEY uq_participants_tenant_email_norm (tenant_id, email_norm),
UNIQUE KEY uq_participants_tenant_user (tenant_id, user_id),
KEY idx_participants_tenant_active_name (tenant_id, active, display_name),
KEY idx_participants_tenant_paypal_name (tenant_id, paypal_name),
KEY idx_participants_user (user_id),
CONSTRAINT fk_participants_tenant
FOREIGN KEY (tenant_id) REFERENCES tenants(id)
ON DELETE CASCADE,
CONSTRAINT fk_participants_user
FOREIGN KEY (user_id) REFERENCES users(id)
ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+35 -18
View File
@@ -24,9 +24,9 @@ das neue Modell umgestellt.
- Kein Billing, keine Tarife, keine E-Mail-Verifikation im ersten Schritt. - Kein Billing, keine Tarife, keine E-Mail-Verifikation im ersten Schritt.
- Kein produktiver Tenant-Wechsel in bestehenden Legacy-Seiten. - Kein produktiver Tenant-Wechsel in bestehenden Legacy-Seiten.
## Vorgeschlagene Migration ## Umgesetzter erster Schritt
Naechste Migration: Umgesetzte Migration:
```text ```text
database/migrations/0002_saas_identity_tenants.sql database/migrations/0002_saas_identity_tenants.sql
@@ -40,6 +40,13 @@ Tabellen:
- `tenant_memberships` - `tenant_memberships`
- `participants` - `participants`
Ergaenzende Skripte:
```text
scripts/backfill-default-tenant.php
scripts/check-m3-saas-basis.php
```
Wichtige Regeln: Wichtige Regeln:
- `users` sind Login-Konten. - `users` sind Login-Konten.
@@ -49,6 +56,8 @@ Wichtige Regeln:
- `participants.legacy_mitarbeiter_id` speichert die alte ID aus - `participants.legacy_mitarbeiter_id` speichert die alte ID aus
`kl_Mitarbeiter`. `kl_Mitarbeiter`.
- Geldwerte in neuen Settings als Cent-Integer speichern. - Geldwerte in neuen Settings als Cent-Integer speichern.
- Die Migration ist rein additiv; bestehende Legacy-Seiten fragen weiter die
bisherigen `kl_*`-Tabellen ab.
## Default-Tenant ## Default-Tenant
@@ -65,9 +74,9 @@ currency_code: EUR
Die Werte koennen spaeter in einer Tenant-Einstellungsseite angepasst werden. Die Werte koennen spaeter in einer Tenant-Einstellungsseite angepasst werden.
## Backfill-Vorschlag ## Backfill
Ein separates Skript sollte die bestehenden Mitarbeiter spiegeln: Ein separates Skript spiegelt die bestehenden Mitarbeiter:
```text ```text
scripts/backfill-default-tenant.php scripts/backfill-default-tenant.php
@@ -87,15 +96,24 @@ Vorgang:
Der Backfill muss idempotent sein und darf keine bestehenden Legacy-Daten Der Backfill muss idempotent sein und darf keine bestehenden Legacy-Daten
loeschen. loeschen.
Ausgefuehrter Dev-Stand:
- Default-Tenant `default` wurde angelegt.
- 9 Legacy-Mitarbeiter wurden als `participants` gespiegelt.
- 2 aktive Admin-/Owner-Logins wurden als `users` und
`tenant_memberships` angelegt.
- Ein zweiter Backfill-Lauf blieb idempotent.
## Erste Akzeptanzkriterien ## Erste Akzeptanzkriterien
- Migrationen laufen mehrfach ohne Fehler. - Migrationen laufen mehrfach ohne Fehler: erfuellt.
- Default-Tenant existiert genau einmal. - Default-Tenant existiert genau einmal: erfuellt.
- `tenant_settings` enthalten Preis pro Strich und bestehende PayPal-Optionen. - `tenant_settings` enthalten Preis pro Strich und bestehende PayPal-Optionen:
- Jeder bestehende `kl_Mitarbeiter` hat genau einen `participant`. erfuellt.
- `participants.legacy_mitarbeiter_id` ist gesetzt. - Jeder bestehende `kl_Mitarbeiter` hat genau einen `participant`: erfuellt.
- Admins werden als tenant-scoped Rolle abgebildet. - `participants.legacy_mitarbeiter_id` ist gesetzt: erfuellt.
- Golden-Master und HTTP-Smoke bleiben gruen. - Admins werden als tenant-scoped Rolle abgebildet: erfuellt.
- Golden-Master und HTTP-Smoke bleiben gruen: erfuellt.
## Risiken ## Risiken
@@ -109,10 +127,9 @@ loeschen.
## Empfohlene Reihenfolge ## Empfohlene Reihenfolge
1. Migration `0002_saas_identity_tenants.sql` erstellen. 1. Migration `0002_saas_identity_tenants.sql` erstellen: erledigt.
2. `scripts/backfill-default-tenant.php` erstellen. 2. `scripts/backfill-default-tenant.php` erstellen: erledigt.
3. Backfill gegen die Dev-Datenbank ausfuehren. 3. Backfill gegen die Dev-Datenbank ausfuehren: erledigt.
4. Kontrollskript fuer Tenant/Participant/Role-Counts schreiben. 4. Kontrollskript fuer Tenant/Participant/Role-Counts schreiben: erledigt.
5. Golden-Master und HTTP-Smoke ausfuehren. 5. Golden-Master und HTTP-Smoke ausfuehren: erledigt.
6. Danach Login-/Registrierungsrouten planen. 6. Danach Login-/Registrierungsrouten planen: naechster Schritt.
+6 -4
View File
@@ -411,10 +411,12 @@ Schritte:
- Tabellen fuer `tenants`, `users`, `tenant_memberships` und - Tabellen fuer `tenants`, `users`, `tenant_memberships` und
`tenant_settings` anlegen. Vorbereitung dokumentiert in `tenant_settings` anlegen. Vorbereitung dokumentiert in
`docs/m3-saas-basis-vorbereitung.md`. `docs/m3-saas-basis-vorbereitung.md`: erster Schritt erledigt.
- Zusaetzlich `participants` als getrennte Kaffee-Teilnehmer-Tabelle anlegen. - Zusaetzlich `participants` als getrennte Kaffee-Teilnehmer-Tabelle anlegen:
- Default-Tenant fuer den aktuellen Bestand anlegen. erledigt.
- Bestehende `kl_Mitarbeiter` idempotent in `participants` spiegeln. - Default-Tenant fuer den aktuellen Bestand anlegen: erledigt.
- Bestehende `kl_Mitarbeiter` idempotent in `participants` spiegeln:
erledigt.
- Registrierung: Tenant + Owner-User + Default-Settings erzeugen. - Registrierung: Tenant + Owner-User + Default-Settings erzeugen.
- Login, Logout, Passwort-Reset und E-Mail-Verifikation bauen. - Login, Logout, Passwort-Reset und E-Mail-Verifikation bauen.
- Tenant-Aufloesung definieren. - Tenant-Aufloesung definieren.
+221
View File
@@ -0,0 +1,221 @@
<?php
declare(strict_types=1);
require __DIR__ . '/dev-db.php';
function m3_env(string $name, string $default): string
{
$value = getenv($name);
if ($value === false || trim($value) === '') {
return $default;
}
return trim($value);
}
function m3_email_norm(?string $email): ?string
{
$normalized = strtolower(trim((string)$email));
return $normalized !== '' ? $normalized : null;
}
function m3_decimal_to_cents(mixed $value): int
{
$normalized = str_replace(',', '.', trim((string)$value));
if ($normalized === '') {
return 0;
}
$negative = str_starts_with($normalized, '-');
if ($negative) {
$normalized = substr($normalized, 1);
}
[$euros, $cents] = array_pad(explode('.', $normalized, 2), 2, '0');
$euros = preg_replace('/[^0-9]/', '', $euros) ?? '0';
$cents = preg_replace('/[^0-9]/', '', $cents) ?? '0';
$cents = str_pad(substr($cents, 0, 2), 2, '0');
$result = ((int)$euros * 100) + (int)$cents;
return $negative ? -$result : $result;
}
function m3_fetch_tenant_id(PDO $pdo, string $slug): int
{
$stmt = $pdo->prepare('SELECT id FROM tenants WHERE slug = ?');
$stmt->execute([$slug]);
$tenantId = $stmt->fetchColumn();
if ($tenantId === false) {
throw new RuntimeException("Tenant '{$slug}' was not created.");
}
return (int)$tenantId;
}
function m3_upsert_user(PDO $pdo, string $email, string $emailNorm, string $displayName): int
{
$stmt = $pdo->prepare(
'INSERT INTO users (email, email_norm, display_name, status)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
email = VALUES(email),
display_name = VALUES(display_name),
status = VALUES(status)'
);
$stmt->execute([$email, $emailNorm, $displayName, 'active']);
$stmt = $pdo->prepare('SELECT id FROM users WHERE email_norm = ?');
$stmt->execute([$emailNorm]);
$userId = $stmt->fetchColumn();
if ($userId === false) {
throw new RuntimeException("User '{$emailNorm}' was not created.");
}
return (int)$userId;
}
$pdo = dev_pdo();
$applied = dev_apply_migrations($pdo);
$tenantSlug = m3_env('M3_DEFAULT_TENANT_SLUG', 'default');
$tenantName = m3_env('M3_DEFAULT_TENANT_NAME', 'Kaffeeliste Bestand');
$ownerEmailNorm = m3_email_norm(getenv('M3_DEFAULT_TENANT_OWNER_EMAIL') ?: null);
$stats = [
'applied_migrations' => count($applied),
'participants' => 0,
'users' => 0,
'memberships' => 0,
];
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare(
'INSERT INTO tenants (slug, name, status, timezone, locale, currency_code)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
status = VALUES(status),
timezone = VALUES(timezone),
locale = VALUES(locale),
currency_code = VALUES(currency_code)'
);
$stmt->execute([$tenantSlug, $tenantName, 'active', 'Europe/Berlin', 'de-DE', 'EUR']);
$tenantId = m3_fetch_tenant_id($pdo, $tenantSlug);
$config = $pdo->query(
'SELECT KostenproStrich, paypaluse, paypallink, strichperweb
FROM kl_config
ORDER BY id
LIMIT 1'
)->fetch() ?: [
'KostenproStrich' => '0.20',
'paypaluse' => 0,
'paypallink' => '',
'strichperweb' => 1,
];
$stmt = $pdo->prepare(
'INSERT INTO tenant_settings
(tenant_id, mark_price_cents, self_entry_enabled, paypal_enabled, paypal_url_template)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
mark_price_cents = VALUES(mark_price_cents),
self_entry_enabled = VALUES(self_entry_enabled),
paypal_enabled = VALUES(paypal_enabled),
paypal_url_template = VALUES(paypal_url_template)'
);
$stmt->execute([
$tenantId,
m3_decimal_to_cents($config['KostenproStrich']),
(int)$config['strichperweb'] === 1 ? 1 : 0,
(int)$config['paypaluse'] === 1 ? 1 : 0,
(string)$config['paypallink'],
]);
$members = $pdo->query(
'SELECT MitarbeiterID, Name, Email, paypalname, aktiv, admin
FROM kl_Mitarbeiter
ORDER BY MitarbeiterID'
)->fetchAll();
$upsertParticipant = $pdo->prepare(
'INSERT INTO participants
(tenant_id, display_name, email, email_norm, paypal_name, active, legacy_mitarbeiter_id)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
display_name = VALUES(display_name),
email = VALUES(email),
email_norm = VALUES(email_norm),
paypal_name = VALUES(paypal_name),
active = VALUES(active)'
);
$upsertMembership = $pdo->prepare(
'INSERT INTO tenant_memberships (tenant_id, user_id, role, status, joined_at)
VALUES (?, ?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE
role = VALUES(role),
status = VALUES(status),
joined_at = COALESCE(tenant_memberships.joined_at, VALUES(joined_at))'
);
$linkParticipantUser = $pdo->prepare(
'UPDATE participants
SET user_id = ?
WHERE tenant_id = ?
AND legacy_mitarbeiter_id = ?'
);
foreach ($members as $member) {
$email = trim((string)$member['Email']);
$emailNorm = m3_email_norm($email);
$displayName = trim((string)$member['Name']);
if ($displayName === '') {
$displayName = $emailNorm ?? ('Mitarbeiter ' . (int)$member['MitarbeiterID']);
}
$upsertParticipant->execute([
$tenantId,
$displayName,
$email !== '' ? $email : null,
$emailNorm,
$member['paypalname'] !== null && trim((string)$member['paypalname']) !== ''
? trim((string)$member['paypalname'])
: null,
(int)$member['aktiv'] === 1 ? 1 : 0,
(int)$member['MitarbeiterID'],
]);
$stats['participants']++;
$isAdmin = (int)$member['admin'] === 1 && (int)$member['aktiv'] === 1;
$isOwner = $ownerEmailNorm !== null && $emailNorm === $ownerEmailNorm && (int)$member['aktiv'] === 1;
if ($emailNorm === null || (!$isAdmin && !$isOwner)) {
continue;
}
$userId = m3_upsert_user($pdo, $email, $emailNorm, $displayName);
$role = $isOwner ? 'owner' : 'admin';
$upsertMembership->execute([$tenantId, $userId, $role, 'active']);
$linkParticipantUser->execute([$userId, $tenantId, (int)$member['MitarbeiterID']]);
$stats['users']++;
$stats['memberships']++;
}
$pdo->commit();
} catch (Throwable $e) {
$pdo->rollBack();
fwrite(STDERR, "M3 default tenant backfill failed: {$e->getMessage()}\n");
exit(1);
}
echo "M3 default tenant backfill complete.\n";
echo "Tenant: {$tenantSlug}\n";
echo "Applied migrations: {$stats['applied_migrations']}\n";
echo "Participants mirrored: {$stats['participants']}\n";
echo "Users upserted: {$stats['users']}\n";
echo "Memberships upserted: {$stats['memberships']}\n";
+111
View File
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
require __DIR__ . '/dev-db.php';
function m3_check_assert(string $label, bool $condition, array &$failures, int &$passes): void
{
if ($condition) {
$passes++;
echo "PASS {$label}\n";
return;
}
$failures[] = $label;
echo "FAIL {$label}\n";
}
$pdo = dev_pdo();
dev_apply_schema($pdo);
$tenantSlug = getenv('M3_DEFAULT_TENANT_SLUG');
$tenantSlug = $tenantSlug !== false && trim($tenantSlug) !== '' ? trim($tenantSlug) : 'default';
$failures = [];
$passes = 0;
$stmt = $pdo->prepare('SELECT * FROM tenants WHERE slug = ?');
$stmt->execute([$tenantSlug]);
$tenants = $stmt->fetchAll();
m3_check_assert('default tenant exists exactly once', count($tenants) === 1, $failures, $passes);
if (count($tenants) !== 1) {
echo "\nM3 SaaS basis check failed: tenant '{$tenantSlug}' is missing or duplicated.\n";
exit(1);
}
$tenantId = (int)$tenants[0]['id'];
$legacyMemberCount = (int)$pdo->query('SELECT COUNT(*) FROM kl_Mitarbeiter')->fetchColumn();
$stmt = $pdo->prepare('SELECT COUNT(*) FROM tenant_settings WHERE tenant_id = ?');
$stmt->execute([$tenantId]);
m3_check_assert('tenant settings exist exactly once', (int)$stmt->fetchColumn() === 1, $failures, $passes);
$stmt = $pdo->prepare('SELECT COUNT(*) FROM participants WHERE tenant_id = ?');
$stmt->execute([$tenantId]);
m3_check_assert('participant count matches legacy members', (int)$stmt->fetchColumn() === $legacyMemberCount, $failures, $passes);
$stmt = $pdo->prepare(
'SELECT COUNT(DISTINCT legacy_mitarbeiter_id)
FROM participants
WHERE tenant_id = ?
AND legacy_mitarbeiter_id IS NOT NULL'
);
$stmt->execute([$tenantId]);
m3_check_assert('legacy member ids are mapped one-to-one', (int)$stmt->fetchColumn() === $legacyMemberCount, $failures, $passes);
$stmt = $pdo->prepare(
'SELECT COUNT(*)
FROM kl_Mitarbeiter m
LEFT JOIN participants p
ON p.tenant_id = ?
AND p.legacy_mitarbeiter_id = m.MitarbeiterID
WHERE p.id IS NULL'
);
$stmt->execute([$tenantId]);
m3_check_assert('no legacy member is missing a participant', (int)$stmt->fetchColumn() === 0, $failures, $passes);
$stmt = $pdo->prepare(
'SELECT COUNT(*)
FROM kl_Mitarbeiter m
LEFT JOIN users u
ON u.email_norm = LOWER(TRIM(m.Email))
LEFT JOIN tenant_memberships tm
ON tm.tenant_id = ?
AND tm.user_id = u.id
WHERE m.aktiv = 1
AND m.admin = 1
AND (tm.id IS NULL OR tm.role NOT IN (\'admin\', \'owner\'))'
);
$stmt->execute([$tenantId]);
m3_check_assert('active legacy admins have tenant memberships', (int)$stmt->fetchColumn() === 0, $failures, $passes);
$stmt = $pdo->prepare(
'SELECT COUNT(*)
FROM kl_Mitarbeiter m
JOIN users u
ON u.email_norm = LOWER(TRIM(m.Email))
JOIN tenant_memberships tm
ON tm.tenant_id = ?
AND tm.user_id = u.id
JOIN participants p
ON p.tenant_id = tm.tenant_id
AND p.legacy_mitarbeiter_id = m.MitarbeiterID
WHERE m.aktiv = 1
AND m.admin = 1
AND p.user_id <> u.id'
);
$stmt->execute([$tenantId]);
m3_check_assert('admin participants link to their login users', (int)$stmt->fetchColumn() === 0, $failures, $passes);
if ($failures !== []) {
echo "\nM3 SaaS basis check failed with " . count($failures) . " failure(s):\n";
foreach ($failures as $failure) {
echo "- {$failure}\n";
}
exit(1);
}
echo "\nM3 SaaS basis check passed with {$passes} assertions.\n";