diff --git a/database/migrations/0002_saas_identity_tenants.sql b/database/migrations/0002_saas_identity_tenants.sql new file mode 100644 index 0000000..87f6114 --- /dev/null +++ b/database/migrations/0002_saas_identity_tenants.sql @@ -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; diff --git a/docs/m3-saas-basis-vorbereitung.md b/docs/m3-saas-basis-vorbereitung.md index 1780501..b78d95c 100644 --- a/docs/m3-saas-basis-vorbereitung.md +++ b/docs/m3-saas-basis-vorbereitung.md @@ -24,9 +24,9 @@ das neue Modell umgestellt. - Kein Billing, keine Tarife, keine E-Mail-Verifikation im ersten Schritt. - Kein produktiver Tenant-Wechsel in bestehenden Legacy-Seiten. -## Vorgeschlagene Migration +## Umgesetzter erster Schritt -Naechste Migration: +Umgesetzte Migration: ```text database/migrations/0002_saas_identity_tenants.sql @@ -40,6 +40,13 @@ Tabellen: - `tenant_memberships` - `participants` +Ergaenzende Skripte: + +```text +scripts/backfill-default-tenant.php +scripts/check-m3-saas-basis.php +``` + Wichtige Regeln: - `users` sind Login-Konten. @@ -49,6 +56,8 @@ Wichtige Regeln: - `participants.legacy_mitarbeiter_id` speichert die alte ID aus `kl_Mitarbeiter`. - 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 @@ -65,9 +74,9 @@ currency_code: EUR 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 scripts/backfill-default-tenant.php @@ -87,15 +96,24 @@ Vorgang: Der Backfill muss idempotent sein und darf keine bestehenden Legacy-Daten 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 -- Migrationen laufen mehrfach ohne Fehler. -- Default-Tenant existiert genau einmal. -- `tenant_settings` enthalten Preis pro Strich und bestehende PayPal-Optionen. -- Jeder bestehende `kl_Mitarbeiter` hat genau einen `participant`. -- `participants.legacy_mitarbeiter_id` ist gesetzt. -- Admins werden als tenant-scoped Rolle abgebildet. -- Golden-Master und HTTP-Smoke bleiben gruen. +- Migrationen laufen mehrfach ohne Fehler: erfuellt. +- Default-Tenant existiert genau einmal: erfuellt. +- `tenant_settings` enthalten Preis pro Strich und bestehende PayPal-Optionen: + erfuellt. +- Jeder bestehende `kl_Mitarbeiter` hat genau einen `participant`: erfuellt. +- `participants.legacy_mitarbeiter_id` ist gesetzt: erfuellt. +- Admins werden als tenant-scoped Rolle abgebildet: erfuellt. +- Golden-Master und HTTP-Smoke bleiben gruen: erfuellt. ## Risiken @@ -109,10 +127,9 @@ loeschen. ## Empfohlene Reihenfolge -1. Migration `0002_saas_identity_tenants.sql` erstellen. -2. `scripts/backfill-default-tenant.php` erstellen. -3. Backfill gegen die Dev-Datenbank ausfuehren. -4. Kontrollskript fuer Tenant/Participant/Role-Counts schreiben. -5. Golden-Master und HTTP-Smoke ausfuehren. -6. Danach Login-/Registrierungsrouten planen. - +1. Migration `0002_saas_identity_tenants.sql` erstellen: erledigt. +2. `scripts/backfill-default-tenant.php` erstellen: erledigt. +3. Backfill gegen die Dev-Datenbank ausfuehren: erledigt. +4. Kontrollskript fuer Tenant/Participant/Role-Counts schreiben: erledigt. +5. Golden-Master und HTTP-Smoke ausfuehren: erledigt. +6. Danach Login-/Registrierungsrouten planen: naechster Schritt. diff --git a/docs/saas-umstrukturierungsplan.md b/docs/saas-umstrukturierungsplan.md index 613d998..cc74dc0 100644 --- a/docs/saas-umstrukturierungsplan.md +++ b/docs/saas-umstrukturierungsplan.md @@ -411,10 +411,12 @@ Schritte: - Tabellen fuer `tenants`, `users`, `tenant_memberships` und `tenant_settings` anlegen. Vorbereitung dokumentiert in - `docs/m3-saas-basis-vorbereitung.md`. -- Zusaetzlich `participants` als getrennte Kaffee-Teilnehmer-Tabelle anlegen. -- Default-Tenant fuer den aktuellen Bestand anlegen. -- Bestehende `kl_Mitarbeiter` idempotent in `participants` spiegeln. + `docs/m3-saas-basis-vorbereitung.md`: erster Schritt erledigt. +- Zusaetzlich `participants` als getrennte Kaffee-Teilnehmer-Tabelle anlegen: + erledigt. +- Default-Tenant fuer den aktuellen Bestand anlegen: erledigt. +- Bestehende `kl_Mitarbeiter` idempotent in `participants` spiegeln: + erledigt. - Registrierung: Tenant + Owner-User + Default-Settings erzeugen. - Login, Logout, Passwort-Reset und E-Mail-Verifikation bauen. - Tenant-Aufloesung definieren. diff --git a/scripts/backfill-default-tenant.php b/scripts/backfill-default-tenant.php new file mode 100644 index 0000000..150bd41 --- /dev/null +++ b/scripts/backfill-default-tenant.php @@ -0,0 +1,221 @@ +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"; diff --git a/scripts/check-m3-saas-basis.php b/scripts/check-m3-saas-basis.php new file mode 100644 index 0000000..81704ad --- /dev/null +++ b/scripts/check-m3-saas-basis.php @@ -0,0 +1,111 @@ +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";